diff --git a/.gitattributes b/.gitattributes index ffcc4445fe8a..24860868b59b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ # Batch files need CRLF for cmd.exe to parse them reliably. *.cmd text eol=crlf *.bat text eol=crlf -# GitHub's GraphQL schema and the operations graphqlgen writes from it. +# GitHub's and Expo's GraphQL schemas and the operations graphqlgen writes from them. packages/source-control-github/schema/** linguist-generated +apps/server/schema/** linguist-generated **/*.graphql.ts linguist-generated diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index ab3fb7deba6b..d53722f57dd7 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -18,13 +18,34 @@ runs: path: ~/.vite-plus key: vite-plus-home-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.vp.outputs.version }} + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: false + run-install: false + + - name: Resolve pnpm store path + id: pnpm_store + shell: bash + run: | + store_path="$(vp pm cache dir)" + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + # setup-vp's shared cache key ignores install filters. Keep full installs in a + # separate namespace, including fallback restores. # The pnpm store stays a cache tarball on the runner's own disk. A store on a # Blacksmith sticky disk mounts in under a second, but pnpm then copies every # package across filesystems instead of hard-linking it: install took 28-49s # against 9s plus an 11s restore. - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Cache full pnpm store + uses: actions/cache@v6 with: - node-version-file: package.json - cache: true - run-install: true + path: ${{ steps.pnpm_store.outputs.path }} + key: pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + restore-keys: | + pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + shell: bash + run: vp install diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs index 64d8c8d6c0bd..f55d29c10dc6 100644 --- a/.github/scripts/check-nightly-release.test.cjs +++ b/.github/scripts/check-nightly-release.test.cjs @@ -61,12 +61,6 @@ for (const releaseChannel of ["stable", "nightly"]) { ); assert.equal(calls.length, 0); }); - - test(`allows a manual ${releaseChannel} release from main`, async () => { - const { options, calls } = releaseSourceFixture(); - await assertReleaseSource({ ...options, releaseChannel }); - assert.equal(calls[0].basehead, "candidate...main"); - }); } for (const comparisonStatus of ["behind", "diverged"]) { @@ -79,13 +73,6 @@ for (const comparisonStatus of ["behind", "diverged"]) { }); } -test("accepts a release commit already contained in main", async () => { - for (const comparisonStatus of ["ahead", "identical"]) { - const { options } = releaseSourceFixture({ comparisonStatus, eventName: "push" }); - await assertCommitOnDefaultBranch({ ...options, sha: "release-commit" }); - } -}); - function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) { const calls = []; return { @@ -119,26 +106,6 @@ test("releases the first nightly when no nightly is published", async () => { assert.equal(await shouldReleaseNightly(options), true); }); -test("waits six hours after publication, including manual nightlies", async () => { - for (const age of [0, 3, 6 - 1 / 3600]) { - const { options, calls } = fixture({ releases: [nightly(age)] }); - assert.equal(await shouldReleaseNightly(options), false); - assert.equal(calls.length, 0); - } -}); - -test("releases new commits at six hours and after an idle period", async () => { - for (const age of [6, 7, 24]) { - const { options } = fixture({ releases: [nightly(age)] }); - assert.equal(await shouldReleaseNightly(options), true); - } -}); - -test("skips unchanged commits after the gap", async () => { - const { options } = fixture({ comparisonStatus: "identical" }); - assert.equal(await shouldReleaseNightly(options), false); -}); - test("uses publication time, not release order or the tagged commit date", async () => { const { options } = fixture({ releases: [nightly(10), nightly(1), nightly(20, { tag_name: "nightly-v0.9.0" })], @@ -153,13 +120,6 @@ test("ignores stable releases and drafts when checking the gap", async () => { assert.equal(await shouldReleaseNightly(options), true); }); -test("compares against the published tag, including legacy nightly tags", async () => { - const tag = "nightly-v0.9.0"; - const { options, calls } = fixture({ releases: [nightly(7, { tag_name: tag })] }); - assert.equal(await shouldReleaseNightly(options), true); - assert.equal(calls[0].basehead, `${tag}...new`); -}); - test("fails instead of releasing when GitHub cannot supply release state", async () => { const { options } = fixture(); options.github.paginate = async () => { @@ -211,8 +171,3 @@ test("stable releases derive the version from legacy nightly tags", async () => }); assert.equal((await resolveLatestNightlyCommit(options)).version, "0.9.0"); }); - -test("stable releases fail without a published nightly", async () => { - const { options } = nightlyCommitFixture({ releases: [nightly(0, { tag_name: "v1.0.0" })] }); - await assert.rejects(resolveLatestNightlyCommit(options), /No published nightly/); -}); diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs new file mode 100644 index 000000000000..d7ebb1a0790c --- /dev/null +++ b/.github/scripts/ci-changes.cjs @@ -0,0 +1,208 @@ +// Decides which CI jobs a change needs. Prints `key=value` lines for +// $GITHUB_OUTPUT; a job runs unless its key is "false". +// +// node ci-changes.cjs --paths gate by the listed paths +// node ci-changes.cjs --paths --full run every Linux job, gate only the macOS lint +// node ci-changes.cjs changed paths unknown: run everything +// +// scripts/check-changed.ts requires the same decisions so local runs match CI. +// +// Every changed path maps to workspace packages, which then pull in their +// dependents from package.json. A path this file does not recognize runs +// everything, so a new root file or directory can only cost minutes. +const fs = require("node:fs"); +const path = require("node:path"); + +const root = path.resolve(__dirname, "../.."); + +// Never read by a build, typecheck, or test. Lint still formats them. +const INERT = [ + /^docs\//, + /^\.(agents|cursor|vscode|devcontainer|macroscope|repos)\//, + /^[^/]+\.md$/, + /^LICENSE$/, +]; + +// Run everything: the root vite.config.ts loads the first as setup for every +// package's tests, and the vendored mobile tarballs are installed dependencies. +const RUNS_EVERYTHING = [ + /^packages\/shared\/src\/(HostProcess|testing\/longTempDir)\.ts$/, + /^apps\/mobile\/deps\//, +]; + +// Paths outside a package, or files inside one that other packages read +// through relative imports rather than a package.json dependency. +const EXTRA_OWNERS = [ + // Vite configs and tsconfigs of these apps import scripts/lib directly. + [/^scripts\/lib\//, ["@t3tools/desktop", "@t3tools/mobile", "@t3tools/web", "t3"]], + // scripts/build-desktop-artifact.ts reads the GNOME extension manifest. + [/^apps\/desktop\/gnome-extension\//, ["@t3tools/scripts"]], + [/^native\/browser-secret\//, ["@t3tools/desktop"]], + [/^native\/kde-snap-shot\//, ["@t3tools/desktop"]], + [/^native\/libghostty-vt\//, ["@t3tools/web"]], +]; +const RUST = /^native\/(resource-monitor|kde-snap-shot|hyprland-snap-shot)\//; + +// Tests that read another package's files without declaring it. The scripts +// tests read app manifests, assets, and sources (cursor-sdk-packaging, +// third-party-licenses, brand-assets, build-desktop-artifact). +const TEST_ONLY_DEPS = { + "@t3tools/provider-pi": ["@t3tools/client-runtime"], + "@t3tools/scripts": [ + "@t3tools/desktop", + "@t3tools/mobile", + "@t3tools/web", + "t3", + "@t3tools/provider-cursor", + ], +}; +// Declared dependencies that only feed a build step: the server bundles the +// built web app (apps/server/vite.config.ts) but never imports it. +const BUILD_ONLY_DEPS = { t3: ["@t3tools/web"] }; + +// Sources scripts/mobile-native-static-check.ts lints, plus the tool and rule +// configuration that decides how it lints them, plus the root package.json +// that defines the lint:mobile command. +const MOBILE_NATIVE = + /^apps\/mobile\/.*\.(swift|kt|kts)$|^apps\/mobile\/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts\/mobile-native-static-check\.ts$|^package\.json$|^\.github\/workflows\/ci\.yml$|^\.github\/scripts\/ci-changes\.cjs$|^\.github\/actions\/ci-changes\//; + +// Jobs that test every package except these, which have jobs of their own. +const OWN_TEST_JOB = new Set(["t3", "@t3tools/web", "@t3tools/monorepo"]); + +function readWorkspace() { + const patterns = []; + let inPackages = false; + for (const line of fs.readFileSync(path.join(root, "pnpm-workspace.yaml"), "utf8").split("\n")) { + if (/^\S/.test(line)) inPackages = line.startsWith("packages:"); + const entry = inPackages && line.match(/^\s+-\s+["']?([^"'\s]+)/); + if (entry) patterns.push(entry[1]); + } + + const dirs = patterns.flatMap((pattern) => { + if (!pattern.endsWith("/*")) return [pattern]; + const parent = pattern.slice(0, -2); + return fs.readdirSync(path.join(root, parent)).map((name) => `${parent}/${name}`); + }); + + const packages = []; + for (const dir of dirs) { + const manifestPath = path.join(root, dir, "package.json"); + if (!fs.existsSync(manifestPath)) continue; + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const deps = { + ...manifest.dependencies, + ...manifest.devDependencies, + ...manifest.peerDependencies, + }; + packages.push({ + name: manifest.name, + dir, + hasTest: Boolean(manifest.scripts?.test), + deps: Object.keys(deps).filter( + (dep) => + String(deps[dep]).startsWith("workspace:") && + !BUILD_ONLY_DEPS[manifest.name]?.includes(dep), + ), + }); + } + return packages; +} + +// Returns the packages a path touches, [] for an inert path, or null when the +// path is not understood and everything must run. +function ownersOf(file, packages) { + if (INERT.some((pattern) => pattern.test(file))) return []; + // A manifest can change dependencies or versions other packages read. + if (path.posix.basename(file) === "package.json") return null; + if (RUNS_EVERYTHING.some((pattern) => pattern.test(file))) return null; + const extra = EXTRA_OWNERS.flatMap(([pattern, names]) => (pattern.test(file) ? names : [])); + // scripts/build-desktop-artifact.test.ts reads the crates' sources. + if (RUST.test(file)) return ["@t3tools/scripts", ...extra]; + + const owner = packages + .filter((pkg) => file.startsWith(`${pkg.dir}/`)) + .sort((a, b) => b.dir.length - a.dir.length)[0]; + if (owner) return [owner.name, ...extra]; + return extra.length > 0 ? extra : null; +} + +function decide(files, packages, full) { + const all = new Set(packages.map((pkg) => pkg.name)); + let affected = new Set(); + let everything = full || files === null; + let rust = false; + + for (const file of files ?? []) { + if (RUST.test(file)) rust = true; + const owners = ownersOf(file, packages); + if (owners === null) { + everything = true; + console.error(`Runs everything: ${file}`); + } else { + for (const owner of owners) affected.add(owner); + } + } + + // Release smoke reads scripts/ itself, not what the scripts tests read. + let scriptsChanged = everything; + if (everything) { + affected = all; + } else { + // Add dependents until nothing changes. + let grew = true; + while (grew) { + grew = false; + for (const pkg of packages) { + if (!affected.has(pkg.name) && pkg.deps.some((dep) => affected.has(dep))) { + affected.add(pkg.name); + grew = true; + } + } + } + scriptsChanged = affected.has("@t3tools/scripts"); + for (const [name, deps] of Object.entries(TEST_ONLY_DEPS)) { + if (deps.some((dep) => affected.has(dep))) affected.add(name); + } + } + + const tested = packages.filter( + (pkg) => pkg.hasTest && !OWN_TEST_JOB.has(pkg.name) && affected.has(pkg.name), + ); + const has = (name) => affected.has(name); + + return { + affected: [...affected].sort(), + outputs: { + typecheck: affected.size > 0, + build: has("@t3tools/desktop") || has("t3") || has("@t3tools/web"), + test: tested.length > 0, + // Empty means every package the Test job covers. + test_packages: everything ? "" : tested.map((pkg) => pkg.name).join(" "), + test_web: has("@t3tools/web"), + test_server: has("t3"), + rust: everything || rust, + release_smoke: scriptsChanged, + mobile_native: files === null || files.some((file) => MOBILE_NATIVE.test(file)), + }, + }; +} + +function main() { + const args = process.argv.slice(2); + const pathsIndex = args.indexOf("--paths"); + const files = + pathsIndex === -1 + ? null + : fs + .readFileSync(args[pathsIndex + 1], "utf8") + .split("\n") + .filter(Boolean); + const { affected, outputs } = decide(files, readWorkspace(), args.includes("--full")); + + console.error(`Affected packages: ${affected.join(", ") || "none"}`); + for (const [key, value] of Object.entries(outputs)) console.log(`${key}=${value}`); +} + +module.exports = { readWorkspace, decide }; + +if (require.main === module) main(); diff --git a/.github/scripts/ci-changes.d.cts b/.github/scripts/ci-changes.d.cts new file mode 100644 index 000000000000..afe6f6133936 --- /dev/null +++ b/.github/scripts/ci-changes.d.cts @@ -0,0 +1,27 @@ +interface WorkspacePackage { + readonly name: string; + readonly dir: string; + readonly hasTest: boolean; + readonly deps: ReadonlyArray; +} + +export function readWorkspace(): Array; + +export function decide( + files: ReadonlyArray | null, + packages: ReadonlyArray, + full: boolean, +): { + affected: Array; + outputs: { + typecheck: boolean; + build: boolean; + test: boolean; + test_packages: string; + test_web: boolean; + test_server: boolean; + rust: boolean; + release_smoke: boolean; + mobile_native: boolean; + }; +}; diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs index a85fec9ee04a..5ed6a6ff498a 100644 --- a/.github/scripts/thread-transfer-report.cjs +++ b/.github/scripts/thread-transfer-report.cjs @@ -244,10 +244,26 @@ async function resolve({ github, context, core }) { let pullNumber = source.pull_requests?.[0]?.number; if (!pullNumber) { - const associated = await github.paginate( - github.rest.repos.listPullRequestsAssociatedWithCommit, - { owner, repo, commit_sha: source.head_sha, per_page: 100 }, - ); + let associated; + try { + associated = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, { + owner, + repo, + commit_sha: source.head_sha, + per_page: 100, + }); + } catch (error) { + // A force-pushed or deleted PR head can disappear before this workflow runs. + if ( + error.status === 422 && + error.response?.data?.message === `No commit found for SHA: ${source.head_sha}` + ) { + core.info(`Skipping CI result for unavailable commit ${source.head_sha}.`); + core.setOutput("publish", "false"); + return; + } + throw error; + } const matchingPulls = associated.filter( (pull) => pull.state === "open" && diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs index 5b1749fc53f4..753c3eb75907 100644 --- a/.github/scripts/thread-transfer-report.test.cjs +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -2,7 +2,6 @@ const assert = require("node:assert/strict"); const test = require("node:test"); const { - renderComment, resolve, upsertCommentForCurrentHead, validateResult, @@ -53,39 +52,6 @@ test("validates the fixed artifact schema", () => { ); }); -test("renders baseline, impact, ceiling, and ceiling changes", () => { - const baseline = result(); - const current = result({ measuredTurnWebSocketWireBytes: 260_000 }); - current.providers.codex.ceiling = { - ...current.providers.codex.ceiling, - measuredTurnWebSocketWireBytes: 330_000, - }; - const comment = renderComment({ - current, - baseline, - currentRun: { - sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - conclusion: "success", - url: "https://github.com/pingdotgg/t3code/actions/runs/2", - }, - baselineRun: { - sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - matchesBase: true, - url: "https://github.com/pingdotgg/t3code/actions/runs/1", - }, - }); - - assert.match(comment, /Main baseline \| This PR \| Impact \| PR ceiling/); - assert.match(comment, /\+9\.8 KiB \(\+4\.0%\)/); - assert.match(comment, /This PR changes transfer ceilings/); - assert.match(comment, /312\.5 KiB → 322\.3 KiB/); - assert.match(comment, //); - assert.match( - comment, - //, - ); -}); - test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => { const outputs = {}; const listWorkflowRunArtifacts = () => {}; @@ -216,6 +182,47 @@ test("does not guess when a fallback commit belongs to multiple PRs", async () = assert.equal(fetchedPull, false); }); +test("skips a missing PR commit but preserves other lookup failures", async () => { + for (const [status, message, shouldSkip] of [ + [422, "No commit found for SHA: old-head", true], + [422, "Validation Failed", false], + [403, "API rate limit exceeded", false], + ]) { + const outputs = {}; + const error = Object.assign(new Error(message), { + status, + response: { data: { message } }, + }); + const run = resolve({ + github: { + paginate: async () => { + throw error; + }, + rest: { repos: { listPullRequestsAssociatedWithCommit: () => {} } }, + }, + context: { + repo: { owner: "pingdotgg", repo: "t3code" }, + payload: { + workflow_run: { event: "pull_request", head_sha: "old-head", pull_requests: [] }, + }, + }, + core: { + info: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + }, + }); + if (shouldSkip) { + await run; + assert.equal(outputs.publish, "false"); + } else { + await assert.rejects(run, (caught) => caught === error); + assert.deepEqual(outputs, {}); + } + } +}); + test("does not report a passing run that skipped the server tests", async () => { const outputs = {}; const listWorkflowRunArtifacts = () => {}; @@ -332,21 +339,3 @@ test("preserves a successful result when a same-SHA rerun has no artifact", asyn assert.equal(published, true); assert.equal(updatedComment, false); }); - -test("accepts V2 without comparing it to the V1 scenario", () => { - const current = result(); - current.scenario.id = "thread-transfer-v2"; - current.providers.codex.ceiling = { ...current.providers.codex.ceiling, totalWireBytes: 3000000 }; - assert.equal(validateResult(current), current); - const comment = renderComment({ - current, - baseline: result(), - currentRun: { sha: "bbbbbbbb", conclusion: "success", url: "https://example.com/current" }, - baselineRun: { sha: "aaaaaaaa", matchesBase: true, url: "https://example.com/baseline" }, - }); - assert.match(comment, /fixture changed/); - assert.doesNotMatch(comment, /This PR changes transfer ceilings/); - assert.doesNotMatch(comment, /[+-]\d+\.\d+%/); - current.scenario.id = "unrecognized"; - assert.throws(() => validateResult(current), /not supported/); -}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a81806e6b89..f99123d841da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,14 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + # electron's install.js (run by ensure:electron) reads this cache before it + # downloads from GitHub. The key changes only with the Electron version. + - name: Cache Electron download + uses: actions/cache@v6 + with: + path: ~/.cache/electron + key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} + - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -55,6 +63,9 @@ jobs: - name: Check generated GitHub GraphQL operations run: vp run --filter @t3tools/source-control-github generate --check + - name: Check generated Expo GraphQL operations + run: vp run --filter t3 generate --check + - name: Check run: vp check @@ -84,6 +95,14 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + # electron's install.js (run by ensure:electron) reads this cache before it + # downloads from GitHub. The key changes only with the Electron version. + - name: Cache Electron download + uses: actions/cache@v6 + with: + path: ~/.cache/electron + key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} + - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron diff --git a/.github/workflows/server-linux-release.yml b/.github/workflows/server-linux-release.yml index 202042d1c5b1..687adac81bba 100644 --- a/.github/workflows/server-linux-release.yml +++ b/.github/workflows/server-linux-release.yml @@ -96,10 +96,9 @@ jobs: - name: Build server bundle and web client run: vp run --filter t3 build + # Setup Vite+ selects the exact engines.node version from package.json, + # which native executable builds require. - name: Build CLI single-executable - env: - # Keep in step with SEA_NODE_VERSION in apps/server/vite.config.ts. - VP_NODE_VERSION: "26.8.2" run: node apps/server/scripts/cli.ts build-exe --verbose # Without the release-repository define the build follows upstream's diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index 52fd09fd1b33..f7cef3929ab1 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -44,6 +44,18 @@ Review changed TypeScript for the conventions below. They apply when a pull requ - Use plain `make` and `layer` in a module named for its implementation (`BunPtyAdapter.ts`). Keep implementation-specific names when one abstract port module holds several implementations (`makeCloudflaredRelayClient`, `layerCloudflared` in `RelayClient.ts`). `infra/relay/src/db.ts` may keep its inline `Layer.succeed(RelayDb, db)`. - When a service moves, delete the old files and update every consumer, including orchestration, MCP, tests, and integration harnesses. Do not leave compatibility re-export shims. +## Effect functions and tracing + +- Reusable functions use named `Effect.fn("Service.operation")`, not functions wrapping + `Effect.gen`. Inline programs, callbacks, and layer construction can keep `Effect.gen`. +- Named `Effect.fn` creates a span; the unnamed form does not. Default to named spans for service + operations and I/O. `Effect.fnUntraced` is deliberate for per-chunk/per-event work, tight loops, + or helpers duplicating an existing tracing boundary; require a nearby reason for new uses. +- Preserve existing span names/options and avoid adding duplicate spans. Pass combinators after + the generator body; transforms receive the original function arguments after the effect. +- Preserve generic signatures, laziness, resource scope, and interruption behavior. An explicit + generator return type is `Effect.fn.Return`. + ## Dependency acquisition and runtime boundaries - Production service construction acquires its Effect dependencies from the environment with `yield* Foo.Foo`, and `make`/`layer` types expose those requirements. Flag a factory that takes `Foo["Service"]` (or an object of Effect-returning methods) as a parameter when that value is a service dependency. Passing service instances explicitly in tests is fine; passing pure configuration, immutable domain values, or deliberate callback strategies is not service injection. @@ -57,6 +69,7 @@ Review changed TypeScript for the conventions below. They apply when a pull requ - Define service failures with `Schema.TaggedError` and structured attributes: operation or stage, resource path or entity identifier, normalized category or status. Derive `message` from those attributes only. Never derive it from `cause`, `cause.message`, or a stringified defect, and do not add a `detail` field that copies `cause.message`. - When wrapping a real failure, keep the immediate underlying error as `cause` so the chain and stack survive. Make `cause` required if every construction wraps a failure. Pure validation or domain errors created without an underlying failure need no cause. - Keep attributes and log annotations safe and bounded: no raw wire payloads, command arguments or output, signed URLs, credentials, query strings, or arbitrary defect text. Preserve the exact value only as `cause`; expose normalized categories, lengths, counts, and safe URL protocol or hostname where useful. + The documented exception is `DeviceBootError.diagnostics`: device hub launch commands and failure text are intentionally exposed to authorized device users and agents, capped at 2,000 characters and included in the error message. Do not apply this exception to other service errors. - At a translation boundary, pass through an already structured domain error when it is part of the target error channel; wrap only unknown or lower-level failures. Map failures where the context is known instead of wrapping a whole multi-step pipeline in one generic error. - Do not encode the same distinction twice with both a specific error tag and a single-value `operation`, `reason`, `kind`, or `phase` literal. Split into separate error classes when a discriminator drives caller control flow or the user-facing message; a discriminator used only for diagnostics may stay a field. Caller-visible messages exposed through HTTP, RPC, persisted state, or UI are behavior and must survive a structural refactor. - Do not add a helper whose only behavior is `(...args) => new SomeError({ ...args })`. Construct the error at the failure boundary. Keep a mapper only when it performs real normalization, passes through domain errors, or adds reusable context; when such a mapper belongs to the target error type, prefer a static factory on that class. diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs new file mode 100644 index 000000000000..e79d0e71dc86 --- /dev/null +++ b/.pnpmfile.cjs @@ -0,0 +1,30 @@ +// Windows WSL dev mode runs the server under Linux from the same node_modules +// as the Windows client, so Windows installs also need Linux x64 glibc native +// modules. Other platforms install only their own binaries; setting this +// everywhere added ~2.2 GB of foreign binaries to every macOS install. +// Release scripts stage their own installs with explicit architectures. +module.exports = { + hooks: { + // `typescript` is @tsc-rs/typescript (the catalog entry). Its npm version (0.8.x) is outside + // vite-plus-core's typescript peer range, so pnpm gave the vite-plus that @effect/vitest + // imports apps/marketing's typescript@6.0.3 instead, and tests loaded a second vitest. + readPackage(pkg) { + if (pkg.name === "@voidzero-dev/vite-plus-core" && pkg.peerDependencies?.typescript) { + pkg.peerDependencies.typescript = "*"; + } + return pkg; + }, + updateConfig(config) { + // oxlint-disable-next-line t3code/no-global-process-runtime -- pnpm loads this file before any Effect runtime exists. + if (process.platform !== "win32") return config; + return { + ...config, + supportedArchitectures: { + os: ["current", "linux"], + cpu: ["current", "x64"], + libc: ["current", "glibc"], + }, + }; + }, + }, +}; diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 000000000000..e3576c995f40 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,2 @@ +# Vercel's default .pnp* rule also excludes pnpm's hook, whose checksum is in the lockfile. +!/.pnpmfile.cjs diff --git a/AGENTS.md b/AGENTS.md index c91bc8d3d991..b8cf5800a619 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,28 +78,60 @@ The most common defect in this repo is a change that works on the path you teste ## Dev servers - `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run. +- Setup runs in the background. While `.t3/setup-running` names a live PID, wait for it and do not run `vp i`. It ends with `.t3/setup-done` or `.t3/setup-failed` (with the error tail). With no marker at all, setup never ran here, so run `vp i` yourself. - `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins. - Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. - Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, then give that full URL to an unpaired browser. Do not wire up `tailscale serve` by hand, open the URL yourself, or consume the user's pairing link. A browser with the reusable dev cookie can use the bare origin. If a normal one-time token was consumed, mint a fresh one with `node apps/server/src/bin.ts pair`. It carries standard scopes, while the startup URL carries admin scopes needed for Connections settings. - To reuse web dev auth across worktrees, configure one fixed `T3CODE_DEV_AUTH_TOKEN` in the main checkout's gitignored `.env`. The `t3.json` setup links that file into worktrees. Never commit or publish the token or a startup URL. See [Reusable dev credential](docs/operations/development.md#reusable-dev-credential). - Stop what you started, by the PID you tracked. See rule 1. +- Need a second checkout? `git worktree add` it next to this worktree, not in `/tmp`. Remove it when done. ## Test data An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state: -- Run `vp run migrate-dev-db` with your dev server stopped. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. +- The first `vp run dev` in a worktree does this for you when its database is missing. `--no-seed` or `T3CODE_DEV_SEED=0` skips it. +- Run `vp run migrate-dev-db` with your dev server stopped to refresh. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. - Refresh `statev2.sqlite`, not `state.sqlite`. The server copies the V1 `state.sqlite` only when `statev2.sqlite` is missing. - Bring `secrets` and `settings.json` only if the flow under test needs them. - Copy in, never symlink. Data flows one way: into your sandbox, never back out. ## Verifying -- Smallest proof that the change works. `vp test run ` for the tests you touched, targeted lint and typecheck for the scope you changed. +- Before you push, run `vp run check:changed`. It runs CI's fast checks for changed files: format, lint, knip, typecheck of affected packages and dependents, and tests beside changes. CI still runs the full test suites. +- While you iterate, run `vp test run `. For big files, add `-t ""`. +- Use `vp run typecheck`, not `npx tsc`; it caches per package. Both run tsc-rs (`typescript` is the `@tsc-rs/typescript` drop-in). +- Run `node -e` snippets from `apps/server` or the owning package. `effect` does not resolve from the repo root. - Test meaningful logic or observable behavior. Do not render components to static markup to assert props or attributes, or add tests that merely assert callback wiring or mirror the implementation. - **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. - Backend behavior changes ship with focused tests for that behavior. -- The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. +- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in the module's existing test file), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. +- The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. Concretely: + - `it.effect` runs on a `TestClock`; `Effect.sleep` only advances through `TestClock.adjust`. `it.live` is wall-clock and only for subprocess or network fixtures. + - No `yieldNow` or `sleep` polling loops. Await the `Deferred`, take from the `Queue`, or drain the worker. + - No per-test timeout argument or `retry`. The project timeout is the ceiling, and flaky tests are fixed, not retried. + - No `performance.now()` or `Date.now()` deltas. Assert on events and counts, not elapsed time. + - Browser tests assert through `expect.element` or `vi.waitFor`, never `requestAnimationFrame` or `transitionend`. + - `vi.mock` factories must not spread a real module that pulls in Shiki or the diff workers. + - `it.skip` and friends need a tracking issue on the line above: `// quarantine: #1234 `. + - Lint enforces this: `t3code/no-test-timeout-or-unreasoned-skip`, `t3code/no-frame-waits-in-browser-tests`, `t3code/no-wall-clock-in-tests`, `t3code/no-real-time-wait-in-tests`, `t3code/no-polling-loop-in-tests`. +- Web browser tests are `*.browser.test.tsx` or `*.browser.test.ts` and render with `await using screen = await render(...)` from `apps/web/test/render.tsx`. `t3code/require-disposable-render` errors on the old patterns. + - Acquire what the test drives after mounting (fake timers it advances, spies it sets up after mounting) after the render, so it disposes before the unmount. Anything the first render reads comes before it. + - A helper that renders and then waits or clicks puts the screen in an `AsyncDisposableStack` and `move()`s it out only on success, so a failed wait still unmounts. + - `rerender` needs a fresh element on every call. + - No lifecycle hooks and no module-level mutable state. Shared mock state that `vi.mock` factories read is reset by a `setup()` call at the top of the test. + - `router` is on by default. Pass `router: false` only with a one-line comment saying why. + + ```tsx + it("saves on Enter", async () => { + const onSave = vi.fn(); + using _now = vi.spyOn(Date, "now").mockReturnValue(0); + await using screen = await render(, { router: "/threads/1" }); + await userEvent.type(screen.getByRole("textbox"), "Title{Enter}"); + expect(onSave).toHaveBeenCalledWith("Title"); + }); + ``` + - Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. For authorized mobile verification, a missing or outdated native client is a build step, not a blocker. Run `node scripts/mobile-native-client.ts ensure ` on the simulator host before starting Metro. It checks the local Expo fingerprint and builds/installs when needed. See `test-t3-mobile` for the full workflow. @@ -112,7 +144,7 @@ For authorized mobile verification, a missing or outdated native client is a bui - UI changes need before/after images. Motion or timing needs a short video. - Upload PR evidence to GitHub. Never commit PR-only screenshots or assets such as `.github/pr-assets/`. - One request is one PR. Split it only when the maintainer asks. Outside contributions follow the stricter [one problem per PR](CONTRIBUTING.md#one-problem) rule. -- When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. +- When babysitting: call `watch_pull_request` and end your turn. T3 Code wakes you on checks, comments, or conflicts. Do not poll or sleep. Verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. ## Documentation @@ -147,7 +179,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ - `packages/contracts` - Effect/Schema contracts plus small derived helpers. No heavy runtime logic. - `packages/shared` - shared runtime utils, subpath exports, no barrel. - `packages/client-runtime` - client code shared by web and mobile. -- `.repos/` - vendored read-only references. Prefer their patterns over invented ones. Never edit or import from them. Sync with `vpr sync:repos` when bumping the matching dependency. +- `.repos/` - vendored read-only references. Prefer their patterns over invented ones. Never edit or import from them. Sync with `vpr sync:repos` when bumping the matching dependency. If it is missing (sparse checkout), read a file with `git show origin/main:.repos/`. ## Taste @@ -155,6 +187,12 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ - Client mutations use `createEnvironmentRpcCommand`. Add protected methods to `CLIENT_GUARDED_RPC_SCOPES` in contracts and use the command's `permissionAtom` for UI availability. Grants are checked at execution against the destination environment; the server remains authoritative. Keep raw RPC clients inside `rpc/`, and extend the permission behavior tests when adding a protected method. - Server features are services; transports stay thin. A `ws.ts` RPC handler, HTTP route, or MCP tool decodes input, calls one service method, and maps errors. See [Effect services](docs/internals/effect-services.md). - `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits and the look is a generic concept, add a variant to the component; a look that belongs to one feature stays in that feature's own component, not in `components/ui`. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` fails lint on violations. See [Web UI](docs/internals/web-ui.md). +- Reusable Effect functions use `Effect.fn("Service.operation")` with a stable span name, not a + function wrapping `Effect.gen`. Keep `Effect.gen` for inline programs and layer construction. + Use `Effect.fnUntraced` only when spans would be too frequent (stream chunks, per-event + projection, tight loops) or duplicate an existing tracing boundary; explain that choice nearby. + Named `Effect.fn` creates a span; the unnamed form does not. Preserve existing span names and + options, and pass combinators as additional function-builder arguments. - Inferred types over annotations. `any` is the enemy. - Comments say why: a constraint, an external quirk, or an invariant the code can't show. Don't restate names or signatures, narrate steps, or record history. `t3code/no-low-value-comments` catches the mechanical cases. - Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. diff --git a/apps/desktop/gnome-extension/captureFeedback.test.js b/apps/desktop/gnome-extension/captureFeedback.test.js index 501f2586f1bf..fb6003bf3970 100644 --- a/apps/desktop/gnome-extension/captureFeedback.test.js +++ b/apps/desktop/gnome-extension/captureFeedback.test.js @@ -151,17 +151,6 @@ it("waits for T3 to remap and cancels that wait when disabled", async () => { expect(shell.mapped.size).toBe(0); }); -it("does not animate under reduced motion but still activates T3", async () => { - shell.animations = false; - shell.windows = [target()]; - const { feedback, started } = begin(); - expect(started).toBe(false); - expect(shell.actors).toHaveLength(0); - await feedback.activate(":1.23", "T3"); - expect(shell.activate).toHaveBeenCalledOnce(); - feedback.dispose(); -}); - it("rejects another sender and locked-session activation", async () => { shell.windows = [target()]; const { feedback } = begin(); diff --git a/apps/desktop/gnome-extension/captureService.test.js b/apps/desktop/gnome-extension/captureService.test.js index b8313f920f72..fa45cd604a0d 100644 --- a/apps/desktop/gnome-extension/captureService.test.js +++ b/apps/desktop/gnome-extension/captureService.test.js @@ -28,11 +28,6 @@ it("rejects other clients before looking at the active window", async () => { expect(await service.capture(":1.23")).toBe("pixels"); }); -it.each(CLIENT_NAMES)("accepts the current owner of %s", async (client) => { - const { service } = fixture({ getNameOwner: async (name) => (name === client ? ":1.23" : null) }); - expect(await service.capture(":1.23")).toBe("pixels"); -}); - it("rechecks the name owner on each capture", async () => { let owner = ":1.23"; const { service } = fixture({ getNameOwner: async () => owner }); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d78ac0fd286a..c652ccabc768 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,13 +1,12 @@ { "name": "@t3tools/desktop", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "main": "dist-electron/boot.cjs", "scripts": { "ensure:electron": "node scripts/ensure-electron-runtime.mjs", "start": "node scripts/start-electron.mjs", - "typecheck": "tsc-rs --noEmit", "test": "vp test run --passWithNoTests", "smoke-test": "node scripts/smoke-test.mjs" }, @@ -24,7 +23,7 @@ "@t3tools/tailscale": "workspace:*", "dbus-next": "0.10.2", "effect": "catalog:", - "electron": "44.4.5", + "electron": "44.9.3", "electron-store": "^8.2.0", "electron-updater": "^6.8.9", "electron-webauthn": "catalog:", @@ -38,7 +37,7 @@ "@types/node": "catalog:", "acorn": "8.16.0", "cross-env": "^10.1.0", - "electron-builder": "26.15.6", + "electron-builder": "26.16.1", "tailwindcss": "^4.0.0", "vite-plus": "catalog:" }, diff --git a/apps/desktop/resources/installer.nsh b/apps/desktop/resources/installer.nsh new file mode 100644 index 000000000000..092f641c3359 --- /dev/null +++ b/apps/desktop/resources/installer.nsh @@ -0,0 +1,10 @@ +!macro customInstall + !ifndef DO_NOT_CREATE_START_MENU_SHORTCUT + ${IfNot} ${FileExists} "$newStartMenuLink" + !insertmacro createMenuDirectory + CreateShortCut "$newStartMenuLink" "$appExe" "" "$appExe" 0 "" "" "${APP_DESCRIPTION}" + ClearErrors + WinShell::SetLnkAUMI "$newStartMenuLink" "${APP_ID}" + ${EndIf} + !endif +!macroend diff --git a/apps/desktop/scripts/build-preview-annotation-css.mjs b/apps/desktop/scripts/build-preview-annotation-css.mjs index a5dbdcfbe69c..943a1f7547bf 100644 --- a/apps/desktop/scripts/build-preview-annotation-css.mjs +++ b/apps/desktop/scripts/build-preview-annotation-css.mjs @@ -29,7 +29,16 @@ const compilerInput = [ annotationSource.replace('@import "tailwindcss";', "@tailwind utilities;"), ].join("\n"); const compiler = await compile(compilerInput, { base: appRoot }); -const css = compiler.build([...candidates]); +// Shadow-root styles cannot register @property defaults. Make Tailwind's +// fallback unconditional so border, shadow, and ring utilities have defaults. +const compiledCss = compiler.build([...candidates]); +const css = compiledCss.replace( + /(@layer properties \{\n) @supports [^\n]*\{\n([\s\S]*?)\n \}\n(\})/, + "$1$2\n$3", +); +if (css === compiledCss || /@layer properties\s*\{\s*@supports\b/.test(css)) { + throw new Error("Tailwind @property fallback was not unwrapped; update the pattern above."); +} const encodedCss = `'${css .replaceAll("\\", "\\\\") .replaceAll("'", "\\'") diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 6cdcb43c9cb6..07578297f06d 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -94,9 +94,14 @@ function cleanupStaleDevApps() { return; } - NodeChildProcess.spawnSync("pkill", ["-f", "--", `--t3code-dev-root=${desktopDir}`], { - stdio: "ignore", - }); + const escapedDesktopDir = desktopDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + NodeChildProcess.spawnSync( + "pkill", + ["-f", "--", `(^| )--t3code-dev-root=${escapedDesktopDir}( |$)`], + { + stdio: "ignore", + }, + ); } function startApp() { diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 780cf639fd18..0fb4377f07be 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -94,7 +94,7 @@ function runChecked(command, args) { throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim()); } -export function resolveMacCodeSignArguments(appBundlePath) { +function resolveMacCodeSignArguments(appBundlePath) { return ["--force", "--deep", "--sign", "-", "--timestamp=none", appBundlePath]; } @@ -201,7 +201,7 @@ function registerMacLauncherBundle(appBundlePath) { } // Bundle-internal paths are macOS paths whatever host builds them. -export function resolveMacLauncherIconPaths(runtimeDir, development = isDevelopment) { +function resolveMacLauncherIconPaths(runtimeDir, development = isDevelopment) { return { sourceIconPath: development ? developmentMacIconPngPath : productionMacIconPngPath, generatedIconPath: NodePath.posix.join( @@ -260,7 +260,7 @@ function ensureMacIconIcns(runtimeDir) { } } -export function resolveMacBundleInfoPlistStrings(executableName) { +function resolveMacBundleInfoPlistStrings(executableName) { return { CFBundleDisplayName: APP_DISPLAY_NAME, CFBundleName: APP_DISPLAY_NAME, diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index c2440f364e7b..fa6e56b5800e 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -1,6 +1,9 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeEvents from "node:events"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeVM from "node:vm"; import { assert, describe, it } from "vite-plus/test"; @@ -8,9 +11,6 @@ import { makeDevelopmentEnvironmentScript, makeDevelopmentLauncherScript, resolveElectronBinaryPath, - resolveMacBundleInfoPlistStrings, - resolveMacCodeSignArguments, - resolveMacLauncherIconPaths, resolveMacLauncherPaths, writeDevelopmentLauncherScript, } from "./electron-launcher.mjs"; @@ -103,30 +103,6 @@ describe("electron development launcher", () => { assert.notInclude(script, "node_modules/electron"); }); - it("declares why the macOS app needs protected access", () => { - const values = resolveMacBundleInfoPlistStrings("T3 Code (Dev) Launcher"); - - assert.equal( - values.NSScreenCaptureUsageDescription, - "T3 Code captures the active window when you use the snapshot shortcut.", - ); - assert.equal( - values.NSDocumentsFolderUsageDescription, - "T3 Code reads project files you open in the desktop app.", - ); - }); - - it("ad-hoc signs the complete development app bundle", () => { - assert.deepEqual(resolveMacCodeSignArguments("/runtime/T3 Code (Dev).app"), [ - "--force", - "--deep", - "--sign", - "-", - "--timestamp=none", - "/runtime/T3 Code (Dev).app", - ]); - }); - it("restores execute permissions on an unchanged launcher", () => { const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-launcher-")); const launcherPath = NodePath.join(directory, "launcher"); @@ -140,14 +116,94 @@ describe("electron development launcher", () => { NodeFS.rmSync(directory, { recursive: true, force: true }); } }); +}); - it("derives launcher icons from canonical development and production assets", () => { - const development = resolveMacLauncherIconPaths("/runtime", true); - const production = resolveMacLauncherIconPaths("/runtime", false); - - assert.match(development.sourceIconPath, /assets[\\/]dev[\\/]blueprint-macos-1024\.png$/); - assert.equal(development.generatedIconPath, "/runtime/icon-dev.icns"); - assert.match(production.sourceIconPath, /assets[\\/]prod[\\/]black-macos-1024\.png$/); - assert.equal(production.generatedIconPath, "/runtime/icon-prod.icns"); - }); +async function startupCleanupPattern(desktopDir) { + const source = NodeFS.readFileSync(new URL("./dev-electron.mjs", import.meta.url), "utf8"); + let pattern; + // Supply inert imports so the real startup runs without launching Electron or signalling processes. + await NodeVM.runInNewContext( + `(async () => { ${source.replace(/^import\s[\s\S]*?;\n/gm, "")} })()`, + { + NodeChildProcess: { + execFileSync: () => undefined, + spawnSync: (command, args) => { + if (command === "pkill" && args.includes("-f")) pattern = args.at(-1); + }, + spawn: () => new NodeEvents.EventEmitter(), + }, + NodeFS: { watch: () => ({ close: () => undefined }) }, + NodeOS: { platform: () => "linux" }, + NodePath, + desktopDir, + resolveDevProtocolClient: () => null, + resolveElectronLaunchCommand: (args) => ({ electronPath: "electron", args }), + waitForResources: async () => undefined, + URL, + process: { + env: { VITE_DEV_SERVER_URL: "http://localhost:1234" }, + execPath: "node", + once: () => undefined, + }, + }, + ); + assert.isString(pattern); + return pattern; +} + +it + // oxlint-disable-next-line t3code/no-global-process-runtime -- Native pgrep is unavailable on Windows. + .skipIf(NodeOS.platform() === "win32") + .each([ + "/tmp/t3-feature.one/apps/desktop", + "/tmp/t3-feature.one [two](three)+?^${four}|back\\slash*/apps/desktop", + ])("dev cleanup matches only the literal root argument: %s", async (desktopDir) => { + const pattern = await startupCleanupPattern(desktopDir); + const flag = `--t3code-dev-root=${desktopDir}`; + const argvCases = [ + [flag], + [flag, "main.cjs"], + [flag.replace(".one", "Xone")], + [`${flag}-two`], + [`prefix${flag}`], + [`--t3code-dev-root=/other${desktopDir}`], + ]; + const children = []; + try { + for (const args of argvCases) { + const child = NodeChildProcess.spawn( + process.execPath, + ["-e", "process.send('ready'); setInterval(() => {}, 1000)", "--", ...args], + { stdio: ["ignore", "ignore", "inherit", "ipc"] }, + ); + children.push(child); + await Promise.race([ + NodeEvents.once(child, "message"), + NodeEvents.once(child, "exit").then(() => { + throw new Error("Fixture exited before readiness"); + }), + ]); + } + // Use the native ERE engine, but never execute the launcher's signal-producing command. + const result = NodeChildProcess.spawnSync( + "pgrep", + ["-P", String(process.pid), "-f", "--", pattern], + { encoding: "utf8" }, + ); + assert.equal(result.status, 0, result.stderr); + const matches = result.stdout.trim().split(/\s+/).map(Number); + assert.sameMembers( + matches, + children.slice(0, 2).map((child) => child.pid), + ); + } finally { + await Promise.all( + children.map(async (child) => { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = NodeEvents.once(child, "exit"); + child.kill("SIGTERM"); + await exited; + }), + ); + } }); diff --git a/apps/desktop/scripts/ensure-electron-runtime.mjs b/apps/desktop/scripts/ensure-electron-runtime.mjs index b8b8254c9b3c..933a763dfa35 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -116,13 +116,39 @@ function runChecked(command, args) { ); } -function installElectronRuntime(electronDir, version) { +// electron's own installer goes through @electron/get, which reuses its +// download cache (~/.cache/electron, ~/Library/Caches/electron) and verifies +// the bundled checksums. pnpm 11 never runs it because electron has no +// install script, so a new worktree would otherwise download 130 MB again. +function installFromElectronCache(electronDir) { + const result = NodeChildProcess.spawnSync( + process.execPath, + [NodePath.join(electronDir, "install.js")], + { stdio: ["ignore", "inherit", "inherit"] }, + ); + if (result.status === 0) { + return true; + } + process.stderr.write( + `electron install.js failed with exit code ${result.status ?? "unknown"}, downloading with curl instead\n`, + ); + return false; +} + +function downloadElectronRuntime(electronDir, version) { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-electron-")); const zipPath = NodePath.join(tempDir, `electron-v${version}-${hostPlatform}-${hostArch}.zip`); try { runChecked("curl", [ "-fsSL", + "--retry", + "3", + "--retry-all-errors", + "--connect-timeout", + "30", + "--max-time", + "300", `https://github.com/electron/electron/releases/download/v${version}/electron-v${version}-${hostPlatform}-${hostArch}.zip`, "-o", zipPath, @@ -142,21 +168,27 @@ function installElectronRuntime(electronDir, version) { } } +function resetRuntime(electronDir) { + NodeFS.rmSync(NodePath.join(electronDir, "dist"), { recursive: true, force: true }); + NodeFS.rmSync(NodePath.join(electronDir, "path.txt"), { force: true }); +} + export function ensureElectronRuntime() { const electronPackageJsonPath = require.resolve("electron/package.json"); const electronPackageJson = JSON.parse(NodeFS.readFileSync(electronPackageJsonPath, "utf8")); const electronDir = NodePath.dirname(electronPackageJsonPath); const platformPath = getPlatformPath(); const electronPath = NodePath.join(electronDir, "dist", platformPath); - const missingBeforeInstall = missingRuntimePaths(electronDir, platformPath); - const invalidBeforeInstall = invalidRuntimePaths(electronDir, platformPath); - - if (missingBeforeInstall.length > 0 || invalidBeforeInstall.length > 0) { - if (NodeFS.existsSync(NodePath.join(electronDir, "dist"))) { - NodeFS.rmSync(NodePath.join(electronDir, "dist"), { recursive: true, force: true }); + const needsInstall = () => + missingRuntimePaths(electronDir, platformPath).length > 0 || + invalidRuntimePaths(electronDir, platformPath).length > 0; + + if (needsInstall()) { + resetRuntime(electronDir); + if (!installFromElectronCache(electronDir) || needsInstall()) { + resetRuntime(electronDir); + downloadElectronRuntime(electronDir, electronPackageJson.version); } - NodeFS.rmSync(NodePath.join(electronDir, "path.txt"), { force: true }); - installElectronRuntime(electronDir, electronPackageJson.version); } const missingAfterInstall = missingRuntimePaths(electronDir, platformPath); diff --git a/apps/desktop/scripts/verify-preload-bundle.test.mjs b/apps/desktop/scripts/verify-preload-bundle.test.mjs index d2055e2c6817..8af6ae00c3f9 100644 --- a/apps/desktop/scripts/verify-preload-bundle.test.mjs +++ b/apps/desktop/scripts/verify-preload-bundle.test.mjs @@ -60,12 +60,6 @@ describe("desktop preload bundle verifier", () => { ); }); - it("ignores import-like text in strings", () => { - assert.doesNotThrow(() => - verifyPreloadBundle(`${validPreload}\nconst message = 'import /* comment */("module")';`), - ); - }); - it("rejects unsupported require calls with comments before the opening parenthesis", () => { assert.throws( () => verifyPreloadBundle(`${validPreload}\nrequire /* @__PURE__ */ ("node:fs");`), @@ -73,13 +67,6 @@ describe("desktop preload bundle verifier", () => { ); }); - it("rejects unsupported optional require calls", () => { - assert.throws( - () => verifyPreloadBundle(`${validPreload}\nrequire?.("node:fs");`), - /unsupported sandbox imports: node:fs/, - ); - }); - it("accepts Electron sandbox module aliases", () => { assert.doesNotThrow(() => verifyPreloadBundle(` diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 68febbc6386f..7dffe9fe3b1f 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -147,7 +147,7 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr const fatalStartupCause = (stage: string, cause: Cause.Cause) => handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); -export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")( +const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")( function* (): Effect.fn.Return { const pool = yield* DesktopBackendPool.DesktopBackendPool; const instances = yield* pool.list; diff --git a/apps/desktop/src/app/DesktopAppActivation.test.ts b/apps/desktop/src/app/DesktopAppActivation.test.ts index 6e0c76842f24..2bf1e716a99a 100644 --- a/apps/desktop/src/app/DesktopAppActivation.test.ts +++ b/apps/desktop/src/app/DesktopAppActivation.test.ts @@ -81,46 +81,6 @@ function exchange(address: string, payload: DesktopAppActivationRequest) { } describe("desktop app control server", () => { - it.effect("roundtrips a request and removes its socket on shutdown", () => - Effect.gen(function* () { - const platform = yield* HostProcess.Platform; - const userId = yield* HostProcess.UserId; - yield* Effect.promise(async () => { - const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-control-test-")); - const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); - const received: DesktopAppActivationRequest[] = []; - const server = await startDesktopAppControlServer({ - ...target, - userId, - handle: async (input) => { - received.push(input); - return { - version: 1, - requestId: input.requestId, - ok: true, - projectId: ProjectId.make("project-1"), - threadId: ThreadId.make("thread-1"), - }; - }, - cancel: () => undefined, - onReclaimError: () => undefined, - }); - openServers.push(server); - - const response = await exchange(target.address, request("request-1", platform)); - - expect(received).toHaveLength(1); - expect(response).toMatchObject({ ok: true, requestId: "request-1" }); - await server.close(); - openServers.splice(openServers.indexOf(server), 1); - if (target.directory !== null) { - await expect(NodeFSP.stat(target.address)).rejects.toMatchObject({ code: "ENOENT" }); - } - await NodeFSP.rm(root, { recursive: true, force: true }); - }); - }), - ); - it.effect("cancels a queued request when the client disconnects", () => Effect.gen(function* () { const platform = yield* HostProcess.Platform; diff --git a/apps/desktop/src/app/DesktopAppActivationBroker.test.ts b/apps/desktop/src/app/DesktopAppActivationBroker.test.ts index 7a889c2e91d3..ab20c7610909 100644 --- a/apps/desktop/src/app/DesktopAppActivationBroker.test.ts +++ b/apps/desktop/src/app/DesktopAppActivationBroker.test.ts @@ -49,44 +49,6 @@ describe("DesktopAppActivationBroker", () => { broker.close(); }); - it("queues requests after unsubscribe until a new renderer registers", async () => { - const previousSend = vi.fn(); - const nextSend = vi.fn(); - const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); - broker.registerRenderer(previousSend); - broker.clearRenderer(); - - const response = broker.request(request); - expect(previousSend).not.toHaveBeenCalled(); - expect(nextSend).not.toHaveBeenCalled(); - - broker.registerRenderer(nextSend); - expect(nextSend).toHaveBeenCalledWith(request); - broker.complete({ - version: 1, - requestId: request.requestId, - ok: true, - projectId: ProjectId.make("project-1"), - threadId: ThreadId.make("thread-1"), - }); - - await expect(response).resolves.toMatchObject({ ok: true }); - broker.close(); - }); - - it("removes a queued request when its CLI connection closes", async () => { - const send = vi.fn(); - const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); - - const response = broker.request(request); - broker.cancel(request.requestId); - broker.registerRenderer(send); - - await expect(response).resolves.toMatchObject({ ok: false, code: "renderer-unavailable" }); - expect(send).not.toHaveBeenCalled(); - broker.close(); - }); - it("never sends a canceled request that was queued behind another request", async () => { const send = vi.fn(); const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); @@ -112,19 +74,4 @@ describe("DesktopAppActivationBroker", () => { expect(send).toHaveBeenCalledTimes(1); broker.close(); }); - - it("times out a request without polling", async () => { - vi.useFakeTimers(); - try { - const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); - const response = broker.request(request); - - await vi.advanceTimersByTimeAsync(1_000); - - await expect(response).resolves.toMatchObject({ ok: false, code: "request-timeout" }); - broker.close(); - } finally { - vi.useRealTimers(); - } - }); }); diff --git a/apps/desktop/src/app/DesktopAppErrors.test.ts b/apps/desktop/src/app/DesktopAppErrors.test.ts deleted file mode 100644 index 666c36d391de..000000000000 --- a/apps/desktop/src/app/DesktopAppErrors.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { - DesktopBackendPortUnavailableError, - DesktopDevelopmentBackendPortRequiredError, -} from "./DesktopApp.ts"; - -describe("DesktopApp errors", () => { - it("preserves unavailable backend port context", () => { - const error = new DesktopBackendPortUnavailableError({ - startPort: 3_773, - maxPort: 65_535, - hosts: ["127.0.0.1", "0.0.0.0", "::"], - }); - - assert.equal(error.startPort, 3_773); - assert.equal(error.maxPort, 65_535); - assert.deepEqual(error.hosts, ["127.0.0.1", "0.0.0.0", "::"]); - assert.equal( - error.message, - "No desktop backend port is available on hosts 127.0.0.1, 0.0.0.0, :: between 3773 and 65535.", - ); - }); - - it("reports the required development port", () => { - const error = new DesktopDevelopmentBackendPortRequiredError(); - - assert.equal(error.message, "T3CODE_PORT is required in desktop development."); - }); -}); diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 1cd8485a1ec9..32e7b3b3118d 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -14,7 +14,6 @@ import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopAssets from "./DesktopAssets.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopUserData from "./DesktopUserData.ts"; const defaultEnvironmentInput = { dirname: "/repo/apps/desktop/dist-electron", @@ -176,66 +175,6 @@ describe("DesktopAppIdentity", () => { ), ); - it.effect("preserves failures while inspecting the legacy userData path", () => { - const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Dev)"; - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "exists", - description: "permission denied", - pathOrDescriptor: legacyPath, - }); - - return withIdentity( - Effect.gen(function* () { - const identity = yield* DesktopAppIdentity.DesktopAppIdentity; - const error = yield* identity.resolveUserDataPath.pipe(Effect.flip); - - assert.instanceOf(error, DesktopUserData.DesktopUserDataInitializationError); - assert.equal(error.resourcePath, legacyPath); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - `Could not initialize Electron user data during inspect at ${legacyPath} (PermissionDenied).`, - ); - }), - { - legacyPathProbeError: cause, - environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } }, - }, - ); - }); - - it.effect("configures app identity from the environment commit override", () => { - const calls: ElectronAppCalls = { - setAboutPanelOptions: [], - setDockIcon: [], - setName: [], - }; - - return withIdentity( - Effect.gen(function* () { - const identity = yield* DesktopAppIdentity.DesktopAppIdentity; - yield* identity.configure; - - assert.deepEqual(calls.setName, ["T3 Code Beta"]); - assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Beta)"); - assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); - assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); - assert.deepEqual(calls.setDockIcon, []); - }), - { - calls, - environment: { - env: { - T3CODE_COMMIT_HASH: "0123456789abcdef", - }, - }, - pngIconPath: Option.some("/icon.png"), - }, - ); - }); - it.effect.each([ { stage: "Beta", environment: {} }, { @@ -267,26 +206,4 @@ describe("DesktopAppIdentity", () => { { calls, environment }, ); }); - - it.effect("sets the dock icon only when running unpackaged", () => { - const calls: ElectronAppCalls = { - setAboutPanelOptions: [], - setDockIcon: [], - setName: [], - }; - - return withIdentity( - Effect.gen(function* () { - const identity = yield* DesktopAppIdentity.DesktopAppIdentity; - yield* identity.configure; - - assert.deepEqual(calls.setDockIcon, ["/icon.png"]); - }), - { - calls, - environment: { isPackaged: false }, - pngIconPath: Option.some("/icon.png"), - }, - ); - }); }); diff --git a/apps/desktop/src/app/DesktopAssets.test.ts b/apps/desktop/src/app/DesktopAssets.test.ts deleted file mode 100644 index 6d2179f0256a..000000000000 --- a/apps/desktop/src/app/DesktopAssets.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import * as NodePath from "@effect/platform-node/NodePath"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as PlatformError from "effect/PlatformError"; - -import * as DesktopAssets from "./DesktopAssets.ts"; -import * as DesktopConfig from "./DesktopConfig.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; - -const layerEnvironment = DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop/dist-electron", - homeDirectory: "/Users/alice", - platform: "darwin", - processArch: "arm64", - appVersion: "1.2.3", - appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", - isPackaged: true, - resourcesPath: "/Applications/T3 Code.app/Contents/Resources", - runningUnderArm64Translation: false, -}).pipe( - Layer.provide( - Layer.mergeAll(NodeServices.layer, NodePath.layerPosix, DesktopConfig.layerTest({})), - ), -); - -describe("DesktopAssets", () => { - it.effect("uses canonical source-tree icons for unpackaged development", () => - Effect.gen(function* () { - const layerDevelopmentEnvironment = DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop/dist-electron", - homeDirectory: "/Users/alice", - platform: "linux", - processArch: "x64", - appVersion: "1.2.3", - appPath: "/repo", - isPackaged: false, - resourcesPath: "/repo/apps/desktop/resources", - runningUnderArm64Translation: false, - }).pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - NodePath.layerPosix, - DesktopConfig.layerTest({ VITE_DEV_SERVER_URL: "http://localhost:5733" }), - ), - ), - ); - const layerFileSystem = FileSystem.layerNoop({ - exists: (path) => Effect.succeed(String(path).includes("/assets/dev/")), - }); - const assets = yield* DesktopAssets.DesktopAssets.pipe( - Effect.provide( - DesktopAssets.layer.pipe( - Layer.provide(Layer.merge(layerFileSystem, layerDevelopmentEnvironment)), - ), - ), - ); - - const icons = yield* assets.iconPaths; - - assert.match(Option.getOrThrow(icons.ico), /assets\/dev\/blueprint-windows\.ico$/); - assert.match(Option.getOrThrow(icons.png), /assets\/dev\/blueprint-universal-1024\.png$/); - assert.isTrue(Option.isNone(icons.icns)); - }), - ); - - it.effect("preserves the failed asset candidate and filesystem cause", () => - Effect.gen(function* () { - const fileName = "custom.bin"; - const candidatePath = "/repo/apps/desktop/resources/custom.bin"; - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "exists", - pathOrDescriptor: candidatePath, - description: "private filesystem diagnostic", - }); - const layerFileSystem = FileSystem.layerNoop({ - exists: (path) => (path === candidatePath ? Effect.fail(cause) : Effect.succeed(false)), - }); - const layerAssets = DesktopAssets.layer.pipe( - Layer.provide(Layer.merge(layerFileSystem, layerEnvironment)), - ); - const assets = yield* DesktopAssets.DesktopAssets.pipe(Effect.provide(layerAssets)); - - const error = yield* assets.resolveResourcePath(fileName).pipe(Effect.flip); - - assert.instanceOf(error, DesktopAssets.DesktopAssetProbeError); - assert.equal(error.fileName, fileName); - assert.equal(error.candidatePath, candidatePath); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - `Failed to probe desktop asset "${fileName}" at ${candidatePath}.`, - ); - assert.notInclude(error.message, "private filesystem diagnostic"); - }), - ); -}); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 58fff8297898..1e572412d469 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -5,7 +5,6 @@ import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/code import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { beforeEach, vi } from "vite-plus/test"; @@ -112,40 +111,6 @@ describe("DesktopClerk", () => { storageMock.mockReset(); }); - it.effect("acquires and releases the SDK bridge with the layer", () => { - const cleanup = vi.fn(); - const events: string[] = []; - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockImplementation(() => { - events.push("createClerkBridge"); - return { cleanup, isPrimaryInstance: true }; - }); - - return Effect.gen(function* () { - yield* Effect.scoped(Layer.build(layerDesktopClerk(true, events))); - - assert.deepEqual(createClerkBridgeMock.mock.calls, [ - [ - { - storage: storageAdapter, - manageSingleInstanceLock: false, - passkeys: true, - renderer: { scheme: "t3code-dev", host: "app" }, - }, - ], - ]); - assert.equal(cleanup.mock.calls.length, 1); - assert.deepEqual(events, [ - "setPath:userData:/tmp/app-data/t3code-dev", - "requestSingleInstanceLock", - "on:second-instance", - "createClerkBridge", - ]); - storageMock.mockClear(); - createClerkBridgeMock.mockClear(); - }); - }); - it.each([ { name: "packaged macOS", @@ -198,54 +163,6 @@ describe("DesktopClerk", () => { }, ); - it.effect("preserves bridge initialization failures", () => { - const cause = new Error("bridge initialization failed"); - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockImplementationOnce(() => { - throw cause; - }); - - return Effect.gen(function* () { - const error = yield* Effect.scoped(Layer.build(layerDesktopClerk())).pipe(Effect.flip); - - assert.instanceOf(error, DesktopClerk.DesktopClerkBridgeInitializationError); - assert.equal(error.stateDir, "/tmp/t3-state"); - assert.equal(error.isDevelopment, true); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - 'Failed to initialize the desktop Clerk bridge for state directory "/tmp/t3-state" (development: true).', - ); - }); - }); - - it.effect("preserves bridge cleanup failures", () => { - const cause = new Error("bridge cleanup failed"); - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ - cleanup: () => { - throw cause; - }, - }); - - return Effect.gen(function* () { - const exit = yield* Effect.exit(Effect.scoped(Layer.build(layerDesktopClerk(false)))); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, DesktopClerk.DesktopClerkBridgeCleanupError); - assert.equal(error.stateDir, "/tmp/t3-state"); - assert.equal(error.isDevelopment, false); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - 'Failed to clean up the desktop Clerk bridge for state directory "/tmp/t3-state" (development: false).', - ); - } - }); - }); - it.effect.each(["before configure", "after configure"] as const)( "reveals the running window when a second instance launches %s", (entry) => { diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 7b44fab01855..c9a79a81d3b5 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -151,22 +151,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { }).pipe(Effect.scoped), ); - it.effect("does not read a large binary another t3 links to", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped(); - const binary = path.join(home, "native-t3"); - yield* fs.writeFileString(binary, `${"\0".repeat(64 * 1024)}${DesktopCliShim.MARKER}`); - yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true }); - yield* fs.symlink(binary, path.join(home, ".local", "bin", "t3")); - const command = yield* commandIn({ home }); - expect((yield* command.state).installedPath).toBeNull(); - yield* command.uninstall; - expect(yield* fs.readLink(path.join(home, ".local", "bin", "t3"))).toBe(binary); - }).pipe(Effect.scoped), - ); - it.effect("refuses to install behind another t3 that runs first", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -237,15 +221,4 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect(registry.path).toBe(userPath); }).pipe(Effect.scoped), ); - - it.effect("offers nothing for a development build", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const command = yield* commandIn({ - home: yield* fs.makeTempDirectoryScoped(), - isPackaged: false, - }); - expect((yield* command.state).supported).toBe(false); - }).pipe(Effect.scoped), - ); }); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index aebe8adb4a05..294f8a8b984e 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -83,15 +83,17 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); - const isOurLink = (link: string) => - Effect.gen(function* () { + const isOurLink = Effect.fn("DesktopCliCommand.isOurLink")( + function* (link: string) { yield* fs.readLink(link); // The launcher is a few KB; never read a large binary another `t3` links to. const info = yield* fs.stat(link); if (info.type !== "File" || Number(info.size) > 16_384) return false; const content = yield* fs.readFileString(link); return content.includes(DesktopCliShim.MARKER); - }).pipe(Effect.orElseSucceed(() => false)); + }, + Effect.orElseSucceed(() => false), + ); const powershell = (env: Record) => Effect.scoped( diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index 92f42dd2b247..e043b4d16d8d 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -64,25 +64,6 @@ const fakeAppImage = (canMount = true) => { }; describe("renderCliShim", () => { - it("runs the AppImage's bundled CLI with every argument and unmounts after", () => { - const { root, image, shim } = fakeAppImage(); - const result = run(shim, ["browser", "setup", "a b"]); - expect(result.status).toBe(3); - expect(result.stdout.split("\n")).toEqual([ - `node=1 cli=${shim} home=${root}/home`, - `${image}/resources/app.asar/apps/server/dist/bin.mjs`, - "browser", - "setup", - "a b", - "", - ]); - NodeChildProcess.spawnSync("sh", [ - "-c", - `i=0; while [ ! -e '${root}/unmounted' ] && [ $i -lt 40 ]; do sleep 0.05; i=$((i+1)); done`, - ]); - expect(NodeFS.existsSync(NodePath.join(root, "unmounted"))).toBe(true); - }); - it("extracts the AppImage where it cannot be mounted", () => { const { shim } = fakeAppImage(false); const result = run(shim, ["--version"], { APPIMAGE_EXTRACT_AND_RUN: "1" }); @@ -124,18 +105,6 @@ describe("renderCliShim", () => { ]); }); - it("tells a person whose app moved to reopen it", () => { - const root = tempRoot(); - const shim = writeShim(root, { - kind: "direct", - executable: NodePath.join(root, "Gone.app/Contents/MacOS/T3 Code"), - entry: "/bin.mjs", - }); - const result = run(shim, ["--version"]); - expect(result.status).toBe(127); - expect(result.stderr).toContain("Open the app once to update this command."); - }); - it("writes a Windows launcher that keeps cmd from reinterpreting paths", () => { const executable = "C:\\Apps\\R&whoami&X 100%\\!CHANNEL!\\T3 Code.exe"; const script = renderCliShim({ @@ -155,14 +124,4 @@ describe("renderCliShim", () => { expect(bare).toEqual([]); expect(lines).toContain('set "T3CODE_CLI_PATH=C:\\Users\\José\\.t3\\bin\\t3.cmd"'); }); - - it("switches the Windows console to UTF-8 only when a path needs it", () => { - const ascii = renderCliShim({ - target: { kind: "windows", executable: "C:\\T3\\T3 Code.exe", entry: "C:\\T3\\bin.mjs" }, - shimPath: "C:\\Users\\me\\.t3\\bin\\t3.cmd", - t3Home: "C:\\Users\\me\\.t3", - }); - expect(ascii).not.toContain("chcp"); - expect(ascii.split("\r\n")).toContain("exit /b %t3_exit%"); - }); }); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 9bc805149ac2..21e25fae21db 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -540,144 +540,6 @@ describe("DesktopConnectionCatalogStore", () => { ), ); - it.effect("surfaces catalog filesystem failures instead of treating them as missing", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const baseFileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-connection-catalog-test-", - }); - const permissionError = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: path.join(baseDir, "userdata", "connection-catalog.json"), - }); - const layerFileSystem = Layer.succeed( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.fail(permissionError), - }), - ); - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( - Effect.provide(layerFor(baseDir, true, null, layerFileSystem)), - ); - - const error = yield* store.get.pipe(Effect.flip); - assert.instanceOf( - error, - DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError, - ); - assert.equal(error.catalogPath, path.join(baseDir, "userdata", "connection-catalog.json")); - assert.strictEqual(error.cause, permissionError); - assert.equal( - error.message, - `Failed to read the desktop connection catalog at ${path.join(baseDir, "userdata", "connection-catalog.json")}.`, - ); - assert.notEqual(error.message, permissionError.message); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("reports the failed catalog write operation and path", () => - Effect.gen(function* () { - const baseFileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-connection-catalog-test-", - }); - const permissionError = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "makeDirectory", - pathOrDescriptor: path.join(baseDir, "userdata"), - }); - const layerFileSystem = Layer.succeed( - FileSystem.FileSystem, - FileSystem.makeNoop({ - makeDirectory: () => Effect.fail(permissionError), - }), - ); - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( - Effect.provide(layerFor(baseDir, true, null, layerFileSystem)), - ); - - const error = yield* store.set("{}").pipe(Effect.flip); - assert.instanceOf( - error, - DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreWriteError, - ); - assert.equal(error.operation, "create-directory"); - assert.equal(error.path, path.join(baseDir, "userdata")); - assert.strictEqual(error.cause, permissionError); - assert.equal( - error.message, - `Desktop connection catalog write failed during create-directory at ${path.join(baseDir, "userdata")}.`, - ); - assert.notEqual(error.message, permissionError.message); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("reports the legacy migration stage", () => - withStore( - Effect.gen(function* () { - const path = yield* Path.Path; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json"); - - const error = yield* store.get.pipe(Effect.flip); - assert.instanceOf( - error, - DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreMigrationError, - ); - assert.equal(error.operation, "read-legacy-registry"); - assert.equal(error.catalogPath, path.join(environment.stateDir, "connection-catalog.json")); - assert.instanceOf( - error.cause, - DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, - ); - const registryError = - error.cause as DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError; - assert.exists(registryError.cause); - assert.equal( - error.message, - `Legacy desktop saved-environment migration failed during read-legacy-registry into ${path.join(environment.stateDir, "connection-catalog.json")}.`, - ); - assert.notEqual(error.message, registryError.message); - }), - ), - ); - - it.effect("reports invalid encrypted catalog data without exposing it", () => - withStore( - Effect.gen(function* () { - const path = yield* Path.Path; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(catalogPath, '{"version":1,"encryptedCatalog":"%%%"}\n'); - - const error = yield* store.get.pipe(Effect.flip); - assert.instanceOf( - error, - DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDecodeError, - ); - assert.equal(error.resource, "encryptedCatalog"); - assert.equal(error.catalogPath, catalogPath); - assert.exists(error.cause); - assert.equal( - error.message, - `Failed to decode encryptedCatalog for the desktop connection catalog at ${catalogPath}.`, - ); - assert.notInclude(error.message, "%%%"); - }), - ), - ); - it.effect("surfaces a catalog that can no longer be decrypted without deleting it", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/desktop/src/app/DesktopDetachedActionErrors.test.ts b/apps/desktop/src/app/DesktopDetachedActionErrors.test.ts deleted file mode 100644 index ae78080539b7..000000000000 --- a/apps/desktop/src/app/DesktopDetachedActionErrors.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; - -import { DesktopLifecycleRelaunchError } from "./DesktopLifecycle.ts"; -import { DesktopApplicationMenuActionError } from "../window/DesktopApplicationMenu.ts"; - -describe("desktop detached action errors", () => { - it("preserves the complete relaunch failure cause and reason", () => { - const cause = Cause.combine( - Cause.fail(new Error("shutdown failed")), - Cause.die(new Error("relaunch defect")), - ); - const error = new DesktopLifecycleRelaunchError({ - reason: "apply update", - cause, - }); - - assert.strictEqual(error.cause, cause); - assert.equal(error.reason, "apply update"); - assert.equal(error.message, 'Desktop relaunch failed for reason "apply update".'); - }); - - it("preserves the complete menu action failure cause and action", () => { - const cause = Cause.combine( - Cause.fail(new Error("window unavailable")), - Cause.die(new Error("dispatch defect")), - ); - const error = new DesktopApplicationMenuActionError({ - action: "open-settings", - cause, - }); - - assert.strictEqual(error.cause, cause); - assert.equal(error.action, "open-settings"); - assert.equal(error.message, 'Desktop menu action "open-settings" failed.'); - }); -}); diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts index 01d1aa1b115e..6d40bd128b77 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -3,7 +3,7 @@ import * as NodePath from "node:path"; import { assert, describe, it } from "@effect/vitest"; import { - resolveEarlyLinuxElectronOptions, + restoreEarlyLinuxDeviceScaleFactor, resolveEarlyLinuxPasswordStorePreference, resolveLinuxDisableFeaturesSwitch, } from "./DesktopEarlyElectronStartup.ts"; @@ -52,83 +52,11 @@ describe("DesktopEarlyElectronStartup", () => { assert.equal(preference, "auto"); }); - it("preserves absolute root paths when resolving early settings", () => { - const preference = resolveEarlyLinuxPasswordStorePreference({ - env: { T3CODE_HOME: "/" }, - homeDirectory: "/home/user", - joinPath, - readFileString: (path) => { - assert.equal(path, "/userdata/desktop-settings.json"); - return JSON.stringify({ linuxPasswordStore: "kwallet6" }); - }, - }); - - assert.equal(preference, "kwallet6"); - }); - - it("resolves the early linux Electron switches", () => { - const options = resolveEarlyLinuxElectronOptions({ - env: { - T3CODE_HOME: "/home/user/.t3-test", - XDG_CURRENT_DESKTOP: "niri", - VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", - }, - homeDirectory: "/home/user", - joinPath, - readFileString: (path) => { - assert.equal(path, "/home/user/.t3-test/userdata/desktop-settings.json"); - return JSON.stringify({ linuxPasswordStore: "auto" }); - }, - }); - - assert.deepEqual(options, { - isDevelopment: true, - linuxWmClass: "t3code-dev", - linuxDesktopEntryName: "com.t3tools.T3Code.Development.desktop", - passwordStore: "gnome-libsecret", - }); - }); - - it("keeps implicit development state under ~/.t3/dev when T3CODE_HOME is unset", () => { - const preference = resolveEarlyLinuxPasswordStorePreference({ - env: { - VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", - }, - homeDirectory: "/home/user", - joinPath, - readFileString: (path) => { - assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); - return JSON.stringify({ linuxPasswordStore: "kwallet" }); - }, - }); - - assert.equal(preference, "kwallet"); - }); - - it("treats whitespace-only T3CODE_HOME as unconfigured in development", () => { - const preference = resolveEarlyLinuxPasswordStorePreference({ - env: { - T3CODE_HOME: " ", - VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", - }, - homeDirectory: "/home/user", - joinPath, - readFileString: (path) => { - assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); - return JSON.stringify({ linuxPasswordStore: "gnome-libsecret" }); - }, - }); - - assert.equal(preference, "gnome-libsecret"); - }); - describe("resolveLinuxDisableFeaturesSwitch", () => { const hyprland = { XDG_CURRENT_DESKTOP: "Hyprland", XDG_SESSION_TYPE: "wayland" }; const releaseCommits: Record = { "0.48.0": "5ee35f914f921e5696030698e74fb5566a804768", "0.48.1": "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc", - "0.49.0": "9958d297641b5c84dcff93f9039d80a5ad37ab00", - "0.50.0": "c4a4c341568944bd4fb9cd503558b2de602c0213", "0.50.1": "4e242d086e20b32951fdc0ebcbfb4d41b5be8dcc", }; const hyprctlVersion = @@ -150,18 +78,15 @@ describe("DesktopEarlyElectronStartup", () => { readHyprctlVersionJson: hyprctlVersion("0.48.1"), }; - it.each(["0.48.0", "0.48.1", "0.49.0", "0.50.0", "0.50.1"])( - "disables the color manager on Hyprland %s", - (version) => { - assert.equal( - resolveLinuxDisableFeaturesSwitch({ - ...brokenHyprland, - readHyprctlVersionJson: hyprctlVersion(version), - }), - "WaylandWpColorManagerV1", - ); - }, - ); + it.each(["0.48.0", "0.50.1"])("disables the color manager on Hyprland %s", (version) => { + assert.equal( + resolveLinuxDisableFeaturesSwitch({ + ...brokenHyprland, + readHyprctlVersionJson: hyprctlVersion(version), + }), + "WaylandWpColorManagerV1", + ); + }); it.each([ { label: "case-insensitive desktop tokens", env: { XDG_CURRENT_DESKTOP: "foo:HYPRLAND" } }, @@ -176,70 +101,6 @@ describe("DesktopEarlyElectronStartup", () => { ); }); - it("keeps existing disabled features", () => { - assert.equal( - resolveLinuxDisableFeaturesSwitch({ - ...brokenHyprland, - disableFeatures: "Vulkan,WaylandWpColorManagerV1Extra", - }), - "Vulkan,WaylandWpColorManagerV1Extra,WaylandWpColorManagerV1", - ); - }); - - it.each([ - { label: "Hyprland 0.47.2", readHyprctlVersionJson: hyprctlVersion("0.47.2") }, - { label: "Hyprland 0.51.0", readHyprctlVersionJson: hyprctlVersion("0.51.0") }, - { label: "Hyprland 0.56.2", readHyprctlVersionJson: hyprctlVersion("0.56.2") }, - { label: "an unrecognized version", readHyprctlVersionJson: hyprctlVersion("1.0.0") }, - { - label: "a malformed version", - readHyprctlVersionJson: hyprctlVersion("0.48.not-a-version"), - }, - { label: "a two-part version", readHyprctlVersionJson: hyprctlVersion("0.48") }, - { label: "a prerelease version", readHyprctlVersionJson: hyprctlVersion("0.49.0-rc1") }, - { - label: "a development build after an affected release", - readHyprctlVersionJson: hyprctlVersion("0.50.1", { tag: "v0.50.1-87-g4e8875b5" }), - }, - { - label: "a later Nix build reporting an affected release tag", - readHyprctlVersionJson: hyprctlVersion("0.50.1", { - commit: "4e8875b5e9700c81ca4e169dc7b85bb5b3c8cb7a", - }), - }, - { - label: "an affected version with another release's commit", - readHyprctlVersionJson: hyprctlVersion("0.48.0", { commit: releaseCommits["0.48.1"] }), - }, - { label: "an unknown patch release", readHyprctlVersionJson: hyprctlVersion("0.50.2") }, - { - label: "missing commit metadata", - readHyprctlVersionJson: () => - JSON.stringify({ version: "0.48.1", tag: "v0.48.1", dirty: false }), - }, - { - label: "a build without git metadata", - readHyprctlVersionJson: hyprctlVersion("0.48.1", { tag: "" }), - }, - { - label: "a dirty build", - readHyprctlVersionJson: hyprctlVersion("0.48.1", { dirty: true }), - }, - { - label: "output without build metadata", - readHyprctlVersionJson: () => JSON.stringify({ version: "0.48.1" }), - }, - { label: "unparseable hyprctl output", readHyprctlVersionJson: () => "ok" }, - { - label: "a missing hyprctl", - readHyprctlVersionJson: () => { - throw new Error("spawnSync hyprctl ENOENT"); - }, - }, - ])("preserves color management for $label", (overrides) => { - assert.isNull(resolveLinuxDisableFeaturesSwitch({ ...brokenHyprland, ...overrides })); - }); - it.each([ { label: "a stale Hyprland signature in KDE", @@ -269,3 +130,70 @@ describe("DesktopEarlyElectronStartup", () => { }); }); }); + +describe("Linux device scale persistence failures", () => { + const input = { + env: { T3CODE_HOME: "/isolated" }, + homeDirectory: "/home/user", + joinPath: NodePath.posix.join, + }; + + it.each(["0", "Infinity", "garbage"])("ignores invalid persisted scale %j", (value) => { + const applied: Array<[string, string]> = []; + const writes: Array = []; + restoreEarlyLinuxDeviceScaleFactor({ + ...input, + commandLine: { + hasSwitch: () => false, + getSwitchValue: () => "", + appendSwitch: (name, scale) => applied.push([name, scale]), + }, + readFileString: () => value, + writeFileString: (_path, contents) => writes.push(contents), + }); + assert.deepEqual(applied, []); + assert.deepEqual(writes, []); + }); + + it("keeps startup available with default scaling when saved state cannot be read", () => { + const applied: Array<[string, string]> = []; + restoreEarlyLinuxDeviceScaleFactor({ + ...input, + commandLine: { + hasSwitch: () => false, + getSwitchValue: () => "", + appendSwitch: (name, value) => applied.push([name, value]), + }, + readFileString: () => { + throw new Error("ENOENT"); + }, + writeFileString: () => {}, + }); + assert.deepEqual(applied, []); + }); + + it("keeps an explicit scale when saving it fails, without falling back to saved state", () => { + const applied: Array<[string, string]> = []; + const reads: Array = []; + const writes: Array = []; + restoreEarlyLinuxDeviceScaleFactor({ + ...input, + commandLine: { + hasSwitch: () => true, + getSwitchValue: () => "1", + appendSwitch: (name, value) => applied.push([name, value]), + }, + readFileString: (path) => { + reads.push(path); + return "1.75"; + }, + writeFileString: (_path, value) => { + writes.push(value); + throw new Error("EACCES"); + }, + }); + assert.deepEqual(writes, ["1"]); + assert.deepEqual(reads, []); + assert.deepEqual(applied, []); + }); +}); diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts index ad2947013771..0015e642672c 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -159,3 +159,43 @@ export function resolveEarlyLinuxElectronOptions( }), }; } + +/** Remember the explicit Chromium scale across AppImage updates, which drop launch arguments. */ +export function restoreEarlyLinuxDeviceScaleFactor( + input: EarlyDesktopSettingsInput & { + readonly commandLine: { + readonly hasSwitch: (name: string) => boolean; + readonly getSwitchValue: (name: string) => string; + readonly appendSwitch: (name: string, value: string) => void; + }; + readonly writeFileString: (path: string, value: string) => void; + }, +): void { + // Keep this separate from desktop-settings.json: the settings service rewrites + // that document, and Chromium needs the value before that service is available. + const path = input.joinPath( + resolveEarlyDesktopSettingsPath(input), + "..", + "linux-device-scale-factor", + ); + const switchName = "force-device-scale-factor"; + const isValid = (value: string) => + value.trim().length > 0 && Number.isFinite(Number(value)) && Number(value) > 0; + if (input.commandLine.hasSwitch(switchName)) { + const value = input.commandLine.getSwitchValue(switchName); + if (isValid(value)) { + try { + input.writeFileString(path, value); + } catch { + // A read-only state directory must not prevent startup with the explicit switch. + } + } + return; + } + try { + const value = input.readFileString(path).trim(); + if (isValid(value)) input.commandLine.appendSwitch(switchName, value); + } catch { + // First launch, missing state, or unreadable state: keep Chromium's default. + } +} diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index fb6c5a8b1afd..200745f11136 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -3,7 +3,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; @@ -39,86 +38,6 @@ const makeEnvironment = ( ) => DesktopEnvironment.DesktopEnvironment.pipe(Effect.provide(layerEnvironment(overrides, env))); describe("DesktopEnvironment", () => { - it.effect("derives state paths and development identity inside Effect", () => - Effect.gen(function* () { - const environment = yield* makeEnvironment( - {}, - { - T3CODE_HOME: " /tmp/t3 ", - T3CODE_COMMIT_HASH: " 0123456789abcdef ", - T3CODE_PORT: "4949", - VITE_DEV_SERVER_URL: "http://localhost:5173", - T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ", - T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ", - T3CODE_OTLP_METRICS_URL: " http://127.0.0.1:4318/v1/metrics ", - T3CODE_OTLP_LOGS_URL: " http://127.0.0.1:4318/v1/logs ", - T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500", - T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3", - T3CODE_OTLP_PROTOCOL: "http/protobuf", - }, - ); - - assert.equal(environment.isDevelopment, true); - assert.equal(environment.appDataDirectory, "/Users/alice/Library/Application Support"); - assert.equal(environment.baseDir, "/tmp/t3"); - assert.equal(environment.stateDir, "/tmp/t3/userdata"); - assert.equal(environment.desktopSettingsPath, "/tmp/t3/userdata/desktop-settings.json"); - assert.equal(environment.clientSettingsPath, "/tmp/t3/userdata/client-settings.json"); - assert.equal( - environment.savedEnvironmentRegistryPath, - "/tmp/t3/userdata/saved-environments.json", - ); - assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); - assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); - assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); - assert.equal(environment.rootDir, "/repo"); - assert.equal(environment.appRoot, "/repo"); - assert.equal(environment.serverRoot, "/repo"); - assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); - assert.equal(environment.backendCwd, "/repo"); - assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); - assert.equal(environment.linuxWmClass, "t3code-dev"); - assert.equal(environment.linuxDesktopEntryName, "com.t3tools.T3Code.Development.desktop"); - assert.deepEqual( - Option.map(environment.devServerUrl, (url) => url.href), - Option.some("http://localhost:5173/"), - ); - assert.deepEqual(environment.devRemoteT3ServerEntryPath, Option.some("/remote/server.mjs")); - assert.deepEqual(environment.configuredBackendPort, Option.some(4949)); - assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef")); - assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces")); - assert.deepEqual(environment.otlpMetricsUrl, Option.some("http://127.0.0.1:4318/v1/metrics")); - assert.deepEqual(environment.otlpLogsUrl, Option.some("http://127.0.0.1:4318/v1/logs")); - assert.equal(environment.otlpExportIntervalMs, 2500); - assert.deepEqual( - environment.otlpHeaders, - Option.some({ - authorization: "Basic abc==", - "x-tenant": "t3", - }), - ); - assert.equal(environment.otlpProtocol, "http/protobuf"); - }), - ); - - it.effect("stores production state under userdata in an explicit home", () => - Effect.gen(function* () { - const environment = yield* makeEnvironment( - {}, - { - T3CODE_HOME: "/tmp/t3", - }, - ); - - assert.equal(environment.isDevelopment, false); - assert.equal(environment.stateDir, "/tmp/t3/userdata"); - assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); - assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); - assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); - assert.equal(environment.otlpProtocol, "http/json"); - }), - ); - it.effect("uses the packaged Windows server sidecar as the backend root", () => Effect.gen(function* () { const environment = yield* makeEnvironment({ @@ -141,19 +60,6 @@ describe("DesktopEnvironment", () => { }), ); - it.effect("uses the stable desktop entry as the packaged Linux portal identity", () => - Effect.gen(function* () { - const environment = yield* makeEnvironment({ - platform: "linux", - isPackaged: true, - appPath: "/tmp/.mount_t3code/resources/app.asar", - resourcesPath: "/tmp/.mount_t3code/resources", - }); - - assert.equal(environment.linuxDesktopEntryName, "com.t3tools.T3Code.desktop"); - }), - ); - it.effect("keeps implicit development state separate from production state", () => Effect.gen(function* () { const development = yield* makeEnvironment( @@ -166,38 +72,4 @@ describe("DesktopEnvironment", () => { assert.equal(production.stateDir, "/Users/alice/.t3/userdata"); }), ); - - it.effect("uses a configured app user model id override", () => - Effect.gen(function* () { - const environment = yield* makeEnvironment( - {}, - { - T3CODE_DESKTOP_APP_USER_MODEL_ID: " com.t3tools.t3code.dev.local ", - VITE_DEV_SERVER_URL: "http://localhost:5173", - }, - ); - - assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev.local"); - }), - ); - - it.effect("resolves picker defaults without nullish sentinels", () => - Effect.gen(function* () { - const environment = yield* makeEnvironment(); - - assert.deepEqual(environment.resolvePickFolderDefaultPath(null), Option.none()); - assert.deepEqual( - environment.resolvePickFolderDefaultPath({ initialPath: " " }), - Option.none(), - ); - assert.deepEqual( - environment.resolvePickFolderDefaultPath({ initialPath: "~" }), - Option.some("/Users/alice"), - ); - assert.deepEqual( - environment.resolvePickFolderDefaultPath({ initialPath: "~/project" }), - Option.some("/Users/alice/project"), - ); - }), - ); }); diff --git a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts index 9deba2389922..122aa3393f9c 100644 --- a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts +++ b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts @@ -34,8 +34,8 @@ const make = Effect.gen(function* () { const markerPath = yield* Ref.make(Option.none()); /** Newest file mtime in a directory; appending to a log leaves the directory's own mtime alone. */ - const newestFileMtime = (directory: string) => - Effect.gen(function* () { + const newestFileMtime = Effect.fn("DesktopLegacyLocalStorage.newestFileMtime")( + function* (directory: string) { let newest = 0; for (const name of yield* fs.readDirectory(directory)) { const info = yield* fs.stat(path.join(directory, name)).pipe(Effect.option); @@ -47,7 +47,9 @@ const make = Effect.gen(function* () { newest = Math.max(newest, mtime); } return newest; - }).pipe(Effect.option); + }, + (effect, _directory) => effect.pipe(Effect.option), + ); const findV1LocalStorage = Effect.gen(function* () { let newest: { readonly directory: string; readonly mtime: number } | null = null; diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index e6900a03b7d2..854f43c5623c 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -2,7 +2,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -10,7 +9,6 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -179,33 +177,6 @@ describe("DesktopLinuxUrlHandler", () => { assert.include(entry, "Icon=/home/al ice/icons/T3\\\\x.png"); }); - it("carries structured context on registration errors", () => { - const writeError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ - step: "write-desktop-entry", - scheme: "t3code", - desktopEntryPath: "/home/alice/.local/share/applications/com.t3tools.T3Code.desktop", - cause: new Error("boom"), - }); - assert.equal( - writeError.message, - "Failed to register the t3code:// URL handler (step: write-desktop-entry).", - ); - assert.equal( - writeError.desktopEntryPath, - "/home/alice/.local/share/applications/com.t3tools.T3Code.desktop", - ); - - const exitError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ - step: "set-default-handler", - scheme: "t3code", - exitCode: 4, - }); - assert.equal( - exitError.message, - "Failed to register the t3code:// URL handler (step: set-default-handler, xdg-mime exit code 4).", - ); - }); - it.effect( "writes the handler entry, refreshes the MIME cache, and claims the scheme default", () => { @@ -222,7 +193,7 @@ describe("DesktopLinuxUrlHandler", () => { ); assert.include( recorded.files[0]?.content, - 'Exec="/home/alice/Applications/T3-Code.AppImage" %U', + "Exec=/home/alice/Applications/T3-Code.AppImage %U", ); assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;"); assert.deepEqual(recorded.commands, [ @@ -247,7 +218,12 @@ describe("DesktopLinuxUrlHandler", () => { assert.include( recorded.files[0]?.content, - `Exec=${DesktopLinuxUrlHandler.escapeDesktopEntryExecArgument(process.execPath)} %U`, + DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ + displayName: "T3 Code (Alpha)", + execTarget: process.execPath, + scheme: "t3code", + iconPath: "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png", + }), ); }); }); @@ -369,26 +345,6 @@ describe("DesktopLinuxUrlHandler", () => { assert.deepEqual(writeFailed.commands, []); }); }); - - it.effect("continues to xdg-mime when the desktop MIME cache refresh stalls", () => - Effect.gen(function* () { - const recorded = emptyRecording(); - const started = yield* Deferred.make(); - const registration = yield* runRegister(recorded, { - updateDesktopDatabaseStalled: true, - updateDesktopDatabaseStarted: started, - }).pipe(Effect.forkChild); - - yield* Deferred.await(started); - yield* TestClock.adjust("5 seconds"); - yield* Fiber.join(registration); - - assert.deepEqual( - recorded.commands.map(({ command }) => command), - ["update-desktop-database", "xdg-mime"], - ); - }), - ); }); interface ThemeIconRun { @@ -648,33 +604,4 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { ); }).pipe(Effect.scoped), ); - - it.effect("continues to xdg-mime when the icon cache refresh stalls", () => - Effect.gen(function* () { - const fixture = yield* themeIconFixture(new Uint8Array([1, 2, 3])); - yield* fixture.seedCache; - const started = yield* Deferred.make(); - const iconCacheOptions: Array> = []; - const run: ThemeIconRun = { - root: fixture.root, - commands: [], - iconCacheOptions, - iconCacheStalled: true, - iconCacheStarted: started, - }; - - const registration = yield* registerWithRealFileSystem(run).pipe(Effect.forkChild); - yield* Deferred.await(started); - yield* TestClock.adjust("5 seconds"); - yield* Fiber.join(registration); - - assert.deepEqual( - run.commands.map(({ command }) => command), - ["gtk-update-icon-cache", "update-desktop-database", "xdg-mime"], - ); - // Without a forced kill, releasing a helper that ignores SIGTERM after - // the timeout would still wait for it to exit. - assert.equal(iconCacheOptions[0]?.forceKillAfter, "1 second"); - }).pipe(Effect.scoped), - ); }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 08cd1d47c08d..e03418964e33 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -75,6 +75,12 @@ export function escapeDesktopEntryExecArgument(value: string): string { return escapeDesktopEntryString(`"${quoted}"`); } +// Legacy xdg-open looks up the first Exec word without unquoting it. +const escapeUrlHandlerExecTarget = (value: string): string => + /[ \t\n"'\\><~|&;$*?#()`]/.test(value) + ? escapeDesktopEntryExecArgument(value) + : escapeDesktopEntryString(value.replaceAll("%", "%%")); + // The AppImage integration entry owns the window identity. This // hidden URL-only entry must not compete with it for StartupWMClass matching. export function renderUrlHandlerDesktopEntry(input: { @@ -87,7 +93,7 @@ export function renderUrlHandlerDesktopEntry(input: { "[Desktop Entry]", "Type=Application", `Name=${escapeDesktopEntryString(input.displayName)}`, - `Exec=${escapeDesktopEntryExecArgument(input.execTarget)} %U`, + `Exec=${escapeUrlHandlerExecTarget(input.execTarget)} %U`, ...(input.iconPath === undefined ? [] : [`Icon=${escapeDesktopEntryString(input.iconPath)}`]), "Terminal=false", "NoDisplay=true", diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index ef462f19a29b..f865ed312063 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -118,76 +118,6 @@ const writeObservabilitySettings = Effect.fn(function* ( }); describe("DesktopObservability", () => { - it("advances a retained output offset instead of repeatedly copying a full head chunk", () => { - const maxBufferedBytes = 1024 * 1024; - const initial = DesktopObservability.appendBoundedOutputChunk( - { - runId: "test-run", - startDetails: "pid=123", - chunks: [], - byteLength: 0, - }, - "stderr", - new Uint8Array(maxBufferedBytes), - ); - const initialBackingBuffer = initial.chunks[0]?.chunk.buffer; - - const next = DesktopObservability.appendBoundedOutputChunk(initial, "stderr", Uint8Array.of(1)); - - assert.equal(next.chunks[0]?.chunk.buffer, initialBackingBuffer); - assert.equal(next.chunks[0]?.offset, 1); - assert.equal(next.byteLength, maxBufferedBytes); - }); - - it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-observability-test-", - }); - const layerEnvironment = layerEnvironmentFor(baseDir); - const tracePath = yield* Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return environment.path.join(environment.logDir, "desktop.trace.ndjson"); - }).pipe(Effect.provide(layerEnvironment)); - const logPath = yield* Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return environment.path.join(environment.logDir, "desktop-main.log"); - }).pipe(Effect.provide(layerEnvironment)); - - yield* Effect.scoped( - Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ "desktop.test": true }); - yield* Effect.logInfo("desktop trace event"); - }).pipe( - Effect.withSpan("desktop-observability-test"), - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - const records = (yield* fileSystem.readFileString(tracePath)) - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => decodeTraceRecordLine(line)); - const record = records.find((entry) => entry.name === "desktop-observability-test"); - - assert.notEqual(record, undefined); - if (!record) { - return; - } - assert.equal(record.attributes["desktop.test"], true); - assert.equal( - record.events.some((event) => event.name === "desktop trace event"), - true, - ); - assert.isFalse(yield* fileSystem.exists(logPath)); - }).pipe( - Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, layerEmptyEnv)), - ), - ); - it.effect("buffers backend child output and persists it only when a failure is reported", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -350,306 +280,6 @@ describe("DesktopObservability", () => { ), ); - it.effect("bounds the number of retained backend child output chunks", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-output-chunks-test-", - }); - const layerEnvironment = layerEnvironmentFor(baseDir, false); - const logPath = yield* Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return environment.path.join(environment.logDir, "server-child.log"); - }).pipe(Effect.provide(layerEnvironment)); - - yield* Effect.scoped( - Effect.gen(function* () { - const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory; - const outputLog = yield* factory.forInstance("primary"); - yield* outputLog.beginSession({ details: "pid=123" }); - for (let index = 0; index < 300; index += 1) { - yield* outputLog.writeOutputChunk("stderr", Uint8Array.of(index % 128)); - } - yield* outputLog.persistFailure({ details: "code=1" }); - }).pipe( - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - const lines = (yield* fileSystem.readFileString(logPath)).trimEnd().split("\n"); - assert.equal(lines.length, 258); - }).pipe( - Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, layerEmptyEnv)), - ), - ); - - it.effect("exports main process log records to the configured logs endpoint", () => { - const requests: Array = []; - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-observability-test-", - }); - const layerEnvironment = layerEnvironmentFor(baseDir, true, { - T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", - T3CODE_OTLP_HEADERS: "x-scope=desktop", - }); - const tracePath = yield* Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return environment.path.join(environment.logDir, "desktop.trace.ndjson"); - }).pipe(Effect.provide(layerEnvironment)); - - yield* Effect.scoped( - Effect.logInfo("desktop log export").pipe( - Effect.withSpan("desktop-log-export-test"), - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - assert.lengthOf(requests, 1); - const [request] = requests; - assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); - assert.include(request?.body ?? "", "desktop log export"); - assert.include(request?.body ?? "", "service.runtime"); - assert.strictEqual(request?.headers["x-scope"], "desktop"); - - const record = (yield* fileSystem.readFileString(tracePath)) - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => decodeTraceRecordLine(line)) - .find((entry) => entry.name === "desktop-log-export-test"); - assert.notEqual(record, undefined); - assert.lengthOf(record?.events ?? [], 0); - }).pipe( - Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), - ); - }); - - it.effect("exports to an OTEL endpoint over Settings, with its own headers and protocol", () => { - const requests: Array = []; - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-observability-test-", - }); - const layerEnvironment = layerEnvironmentFor(baseDir, true, { - T3CODE_OTLP_HEADERS: "x-scope=desktop", - }); - yield* writeObservabilitySettings(layerEnvironment, { - otlpLogsUrl: "https://settings.example.com/v1/logs", - }); - - yield* Effect.scoped( - Effect.logInfo("desktop otel export").pipe( - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - assert.lengthOf(requests, 1); - const [request] = requests; - assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); - assert.strictEqual(request?.headers["x-otel"], "desktop"); - assert.strictEqual(request?.headers["x-scope"], undefined); - assert.strictEqual(request?.headers["content-type"], "application/json"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - layerCollector(requests), - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_HEADERS: "x-otel=desktop", - OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/json", - }, - }), - ), - ), - ), - ); - }); - - it.effect("keeps its service name while OTEL resource attributes add dimensions", () => { - const requests: Array = []; - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-observability-test-", - }); - const layerEnvironment = layerEnvironmentFor(baseDir, true, { - T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", - }); - - yield* Effect.scoped( - Effect.logInfo("desktop service name").pipe( - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - assert.lengthOf(requests, 1); - const body = requests[0]?.body ?? ""; - assert.include(body, '"stringValue":"t3code-desktop"'); - assert.include(body, "deployment.environment.name"); - assert.include(body, '"key":"service.namespace","value":{"stringValue":"t3code"}'); - assert.notInclude(body, "renamed"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - layerCollector(requests), - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - OTEL_SERVICE_NAME: "renamed", - OTEL_RESOURCE_ATTRIBUTES: - "service.name=renamed,service.namespace=renamed,deployment.environment.name=development", - }, - }), - ), - ), - ), - ); - }); - - it.effect("exports nothing to Settings for logs an unusable OTEL endpoint claimed", () => { - const requests: Array = []; - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-observability-test-", - }); - const layerEnvironment = layerEnvironmentFor(baseDir, true, { - T3CODE_OTLP_HEADERS: "x-scope=desktop", - }); - yield* writeObservabilitySettings(layerEnvironment, { - otlpLogsUrl: "https://settings.example.com/v1/logs", - }); - - yield* Effect.scoped( - Effect.logInfo("desktop otel off").pipe( - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - assert.lengthOf(requests, 0); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - layerCollector(requests), - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "grpc", - }, - }), - ), - ), - ), - ); - }); - - it.effect("exports kill switch warnings through the configured logger", () => { - const requests: Array = []; - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-observability-test-", - }); - const layerEnvironment = layerEnvironmentFor(baseDir, true, { - T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", - }); - - yield* Effect.scoped( - Effect.void.pipe( - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - assert.include(requests[0]?.body ?? "", "OTEL_SDK_DISABLED=1 was read as false"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - layerCollector(requests), - ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "1" } })), - ), - ), - ); - }); - - it.effect("reads every signal endpoint from Settings when the environment names none", () => { - const requests: Array = []; - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-observability-test-", - }); - const layerEnvironment = layerEnvironmentFor(baseDir); - yield* writeObservabilitySettings(layerEnvironment, { - otlpTracesUrl: "https://settings.example.com/v1/traces", - otlpLogsUrl: "https://settings.example.com/v1/logs", - otlpMetricsUrl: "https://settings.example.com/v1/metrics", - }); - - yield* Effect.scoped( - Effect.logInfo("desktop log export from settings").pipe( - Effect.withSpan("desktop-settings-export-test"), - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - assert.deepEqual(requests.map((request) => request.url).toSorted(), [ - "https://settings.example.com/v1/logs", - "https://settings.example.com/v1/traces", - ]); - assert.include( - requests.find((request) => request.url.endsWith("/v1/logs"))?.body ?? "", - "desktop log export from settings", - ); - assert.include( - requests.find((request) => request.url.endsWith("/v1/traces"))?.body ?? "", - "desktop-settings-export-test", - ); - }).pipe( - Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), - ); - }); - - it.effect("stays off the network when no endpoint is configured", () => { - const requests: Array = []; - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-observability-test-", - }); - - yield* Effect.scoped( - Effect.logInfo("desktop log stays local").pipe( - Effect.withSpan("desktop-offline-test"), - Effect.provide( - DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironmentFor(baseDir))), - ), - ), - ); - - assert.lengthOf(requests, 0); - }).pipe( - Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), - ); - }); - it.effect("stops every export when the OpenTelemetry SDK is disabled", () => { const requests: Array = []; return Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 299db7019dd7..906ca1f8e4a2 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -150,7 +150,7 @@ interface BackendOutputSession { readonly byteLength: number; } -export function appendBoundedOutputChunk( +function appendBoundedOutputChunk( session: BackendOutputSession, streamName: "stdout" | "stderr", chunk: Uint8Array, diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts index 542f6b987594..e76c29eee1e2 100644 --- a/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts @@ -4,7 +4,6 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as DesktopPreReadyFileSystem from "./DesktopPreReadyFileSystem.ts"; import * as DesktopUserData from "./DesktopUserData.ts"; @@ -17,21 +16,6 @@ const resolveWindowsUserData = (appDataDirectory: string) => }).pipe(Effect.provide(DesktopPreReadyFileSystem.layer)); it.layer(NodeServices.layer)("DesktopPreReadyFileSystem", (it) => { - it.effect("migrates the legacy Windows profile state", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-pre-ready-fs-" }); - yield* fileSystem.makeDirectory(path.join(root, "T3 Code (Alpha)")); - yield* fileSystem.writeFileString(path.join(root, "T3 Code (Alpha)", "Local State"), "keys"); - - const userData = yield* resolveWindowsUserData(root); - - assert.equal(userData, path.join(root, "t3code-v2")); - assert.equal(yield* fileSystem.readFileString(path.join(userData, "Local State")), "keys"); - }), - ); - it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32" || process.getuid?.() === 0)( "fails instead of treating an unreadable profile as missing", () => diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index dd15f879fd8e..143a6e5fd87c 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -15,6 +15,7 @@ const { writeFileSyncMock, copyFileSyncMock, execFileSyncMock, + readFileSyncMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), getSwitchValueMock: vi.fn(), @@ -25,6 +26,7 @@ const { writeFileSyncMock: vi.fn(), copyFileSyncMock: vi.fn(), execFileSyncMock: vi.fn(), + readFileSyncMock: vi.fn(), })); vi.mock("electron", () => ({ @@ -45,7 +47,7 @@ vi.mock("electron", () => ({ })); vi.mock("node:fs", () => ({ - readFileSync: () => "{}", + readFileSync: readFileSyncMock, mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, copyFileSync: copyFileSyncMock, @@ -68,73 +70,51 @@ describe("DesktopPreReadyPlatform", () => { writeFileSyncMock.mockReset(); copyFileSyncMock.mockReset(); execFileSyncMock.mockReset(); + readFileSyncMock.mockReset().mockReturnValue("{}"); }); - it.effect("preserves an explicit Linux password-store switch", () => { - hasSwitchMock.mockImplementation((switchName) => switchName === "password-store"); - getSwitchValueMock.mockReturnValue(" basic "); + it.effect("persists Linux scaling synchronously across relaunches and settings rewrites", () => { + vi.stubEnv("T3CODE_HOME", "/isolated"); + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + const settingsPath = "/isolated/userdata/desktop-settings.json"; + const files = new Map([[settingsPath, "{}"]]); + const commandLine = new Map(); + readFileSyncMock.mockImplementation((path: string) => { + const contents = files.get(path); + if (contents === undefined) throw new Error("ENOENT"); + return contents; + }); + writeFileSyncMock.mockImplementation((path: string, contents: string) => { + files.set(path, contents); + }); + hasSwitchMock.mockImplementation((name: string) => commandLine.has(name)); + getSwitchValueMock.mockImplementation((name: string) => commandLine.get(name) ?? ""); + appendSwitchMock.mockImplementation((name: string, value: string) => { + commandLine.set(name, value); + }); + const launch = Effect.gen(function* () { + const startup = Promise.resolve().then(() => commandLine.get("force-device-scale-factor")); + yield* DesktopPreReadyPlatform.make; + return yield* Effect.promise(() => startup); + }); return Effect.gen(function* () { - const options = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; + for (const explicitScale of ["1.75", "1"]) { + commandLine.clear(); + commandLine.set("force-device-scale-factor", explicitScale); + appendSwitchMock.mockClear(); + assert.equal(yield* launch, explicitScale); + assert.isFalse( + appendSwitchMock.mock.calls.some(([name]) => name === "force-device-scale-factor"), + ); - assert.equal(options.linuxPasswordStoreCommandLine, "basic"); - assert.isFalse(appendSwitchMock.mock.calls.some(([name]) => name === "password-store")); + // The settings service discards fields it does not own when rewriting its document. + files.set(settingsPath, JSON.stringify({ linuxPasswordStore: "basic" })); + commandLine.clear(); + assert.equal(yield* launch, explicitScale); + } }).pipe( - Effect.provide( - DesktopPreReadyPlatform.layer.pipe( - Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), - ), - ), - ); - }); - - it.effect.each([ - { - label: "merges the color-manager fallback into disabled features on Hyprland 0.48.1", - switches: { "ozone-platform": "wayland", "disable-features": "Vulkan" }, - hyprlandVersion: "0.48.1", - hyprlandCommit: "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc", - expected: [["disable-features", "Vulkan,WaylandWpColorManagerV1"]], - }, - { - label: "preserves color management on Hyprland 0.51.0", - switches: { "ozone-platform": "wayland", "disable-features": "Vulkan" }, - hyprlandVersion: "0.51.0", - hyprlandCommit: "0123456789abcdef0123456789abcdef01234567", - expected: [], - }, - { - label: "preserves color management when Electron resolved X11", - switches: { "ozone-platform": "x11", "disable-features": "Vulkan" }, - hyprlandVersion: "0.48.1", - hyprlandCommit: "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc", - expected: [], - }, - ])("$label", ({ switches, hyprlandVersion, hyprlandCommit, expected }) => { - vi.stubEnv("XDG_CURRENT_DESKTOP", "Hyprland"); - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - const commandLine = new Map(Object.entries(switches)); - hasSwitchMock.mockImplementation((switchName: string) => commandLine.has(switchName)); - getSwitchValueMock.mockImplementation( - (switchName: string) => commandLine.get(switchName) ?? "", - ); - execFileSyncMock.mockReturnValue( - JSON.stringify({ - commit: hyprlandCommit, - version: hyprlandVersion, - dirty: false, - tag: `v${hyprlandVersion}`, - }), - ); - - return DesktopPreReadyPlatform.make.pipe( Effect.provideService(HostProcess.Platform, "linux"), - Effect.map(() => { - const featureSwitches = appendSwitchMock.mock.calls.filter(([name]) => - ["disable-features", "enable-features", "ozone-platform"].includes(name), - ); - assert.deepEqual(featureSwitches, expected); - }), Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), ); }); @@ -174,7 +154,7 @@ describe("DesktopPreReadyPlatform", () => { ); const identity = yield* Effect.promise(() => portalIdentity); assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop"); - assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); + assert.include(identity.desktopEntry ?? "", "Exec=/Applications/current.AppImage %U"); assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Beta)"); assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); assert.include( @@ -186,32 +166,6 @@ describe("DesktopPreReadyPlatform", () => { ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); }); - it.effect("keeps startup available when the early desktop entry cannot be written", () => { - getSwitchValueMock.mockReturnValue(""); - mkdirSyncMock.mockImplementation(() => { - throw new Error("read-only filesystem"); - }); - - return DesktopPreReadyPlatform.make.pipe( - Effect.provideService(HostProcess.Platform, "linux"), - Effect.asVoid, - ); - }); - - it.effect("still prepares the portal entry when the bundled icon cannot be copied", () => { - getSwitchValueMock.mockReturnValue(""); - copyFileSyncMock.mockImplementation(() => { - throw new Error("missing bundled icon"); - }); - return Effect.gen(function* () { - yield* DesktopPreReadyPlatform.make; - const contents = writeFileSyncMock.mock.calls[0]?.[1]; - assert.include(contents, "MimeType=x-scheme-handler/t3code;"); - assert.include(contents, "Icon="); - assert.equal(setDesktopNameMock.mock.calls.length, 1); - }).pipe(Effect.provideService(HostProcess.Platform, "linux")); - }); - it.effect( "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer", () => diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index a90246c73b3b..58670439a525 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -62,6 +62,17 @@ export const make = Effect.gen(function* () { platform === "linux" ? resolveEarlyLinuxElectronOptionsFromProcess(homeDirectory) : null; if (linux !== null) { + DesktopEarlyElectronStartup.restoreEarlyLinuxDeviceScaleFactor({ + env: process.env, + homeDirectory, + joinPath: NodePath.posix.join, + commandLine: Electron.app.commandLine, + readFileString: (path) => NodeFS.readFileSync(path, "utf8"), + writeFileString: (path, value) => { + NodeFS.mkdirSync(NodePath.posix.dirname(path), { recursive: true }); + NodeFS.writeFileSync(path, value, "utf8"); + }, + }); // The portal also requires a valid desktop entry. An AppImage update may // have removed the executable referenced by the previous launch's entry. try { diff --git a/apps/desktop/src/app/DesktopUserData.test.ts b/apps/desktop/src/app/DesktopUserData.test.ts index 41e497463a0d..78bab4085627 100644 --- a/apps/desktop/src/app/DesktopUserData.test.ts +++ b/apps/desktop/src/app/DesktopUserData.test.ts @@ -3,40 +3,9 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import { resolveUserDataPath } from "./DesktopUserData.ts"; -it.effect("identifies a failed source read and preserves its cause", () => { - const sourceState = "/profiles/t3code/Local State"; - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: sourceState, - }); - return Effect.gen(function* () { - const error = yield* resolveUserDataPath({ - appDataDirectory: "/profiles", - isDevelopment: false, - platform: "win32", - }).pipe(Effect.flip); - assert.equal(error.operation, "read"); - assert.equal(error.resourcePath, sourceState); - assert.equal(error.category, "PermissionDenied"); - assert.strictEqual(error.cause, cause); - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: (path) => Effect.succeed(path === sourceState), - readFileString: () => Effect.fail(cause), - }), - ), - Effect.provide(NodeServices.layer), - ); -}); - it.effect.each(["t3code", "T3 Code (Alpha)"])( "preserves Windows credential keys from %s without copying browser databases", (sourceName) => diff --git a/apps/desktop/src/app/DesktopWebLinks.test.ts b/apps/desktop/src/app/DesktopWebLinks.test.ts index 8c97b5fe8356..9e87cfb7a9c0 100644 --- a/apps/desktop/src/app/DesktopWebLinks.test.ts +++ b/apps/desktop/src/app/DesktopWebLinks.test.ts @@ -36,22 +36,6 @@ const makeWindow = () => { }; describe("DesktopWebLinks", () => { - it("treats only http and https as web links", () => { - assert.isTrue(DesktopWebLinks.isWebLink("https://example.com/")); - assert.isTrue(DesktopWebLinks.isWebLink("http://localhost:3000/a?b=1")); - assert.isFalse(DesktopWebLinks.isWebLink("t3code://app/welcome")); - assert.isFalse(DesktopWebLinks.isWebLink("mailto:hello@example.com")); - assert.isFalse(DesktopWebLinks.isWebLink("not a url")); - }); - - it("opens HTML files the way a browser does, and nothing else by path", () => { - assert.isTrue(DesktopWebLinks.isWebPageFile("/Users/me/report.html")); - assert.isTrue(DesktopWebLinks.isWebPageFile("/tmp/page.HTM")); - assert.isTrue(DesktopWebLinks.isWebPageFile("/tmp/page.xhtml")); - assert.isFalse(DesktopWebLinks.isWebPageFile("/tmp/notes.md")); - assert.isFalse(DesktopWebLinks.isWebPageFile("/tmp/html")); - }); - it.effect("holds links until the renderer listens, then delivers them in order", () => { const { sent, revealed, layer } = makeWindow(); return Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index dd6809a1034d..6ec028c0d7c6 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -5,12 +5,9 @@ import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; -import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -22,22 +19,6 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts"; -const PersistedServerObservabilitySettingsDocument = Schema.Struct({ - observability: Schema.Struct({ - otlpTracesUrl: Schema.String, - otlpMetricsUrl: Schema.String, - otlpLogsUrl: Schema.String, - }), -}); - -const encodePersistedServerObservabilitySettingsDocument = Schema.encodeEffect( - Schema.fromJsonString(PersistedServerObservabilitySettingsDocument), -); - -const isDesktopBackendObservabilitySettingsReadError = Schema.is( - DesktopBackendConfiguration.DesktopBackendObservabilitySettingsReadError, -); - const layerShellEnvironment = (prepared = true) => Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, { installIntoProcess: Effect.succeed(prepared), @@ -340,44 +321,6 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("only the local primary bootstrap claims the prepared shell environment", () => - withHarness( - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - - const primary = yield* configuration.resolvePrimary; - const wsl = yield* configuration.resolveWsl({ port: 5000, distro: null }); - - assert.equal(primary.bootstrap.shellEnvironmentPrepared, true); - assert.isUndefined(wsl.bootstrap.shellEnvironmentPrepared); - }), - ), - ); - - it.effect("keeps server hydration enabled when desktop preparation failed", () => - withHarness( - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const primary = yield* configuration.resolvePrimary; - assert.equal(primary.bootstrap.shellEnvironmentPrepared, false); - }), - false, - ), - ); - - it.effect("resolveWsl reuses the primary's bootstrap token", () => - withHarness( - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - - const primary = yield* configuration.resolvePrimary; - const wsl = yield* configuration.resolveWsl({ port: 5000, distro: null }); - - assert.equal(wsl.bootstrap.desktopBootstrapToken, primary.bootstrap.desktopBootstrapToken); - }), - ), - ); - it.effect("resolveWsl pins a default-tracking run to the concrete default distro", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -600,6 +543,7 @@ describe("DesktopBackendConfiguration", () => { archiveHash: "b".repeat(64), wsl: () => ({ prepareRuntime: () => ({ ok: false, reason: "archive is corrupt" }), + invalidateRuntime: () => Effect.die("failed staging must not invalidate a runtime"), ensureNodePty: (_distro, root) => { observedNodePtyRoots.push(root); return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; @@ -664,42 +608,6 @@ describe("DesktopBackendConfiguration", () => { ); }); - it.effect("resolveWsl keeps the staged runtime when the mounted tree fails too", () => { - const stagedAppRoot = "/home/test/.t3/wsl-runtime/cache"; - const invalidatedRuntimeIds: string[] = []; - return withPackagedWslHarness( - { - archiveHash: "d".repeat(64), - wsl: () => ({ - prepareRuntime: () => ({ ok: true, linuxAppRoot: stagedAppRoot }), - invalidateRuntime: (_distro, runtimeId) => - Effect.sync(() => { - invalidatedRuntimeIds.push(runtimeId); - }), - probeRuntime: () => ({ - ok: false, - reason: "unsupported CPU architecture or incompatible system libraries", - }), - ensureNodePty: () => ({ - ok: false, - reason: "mounted tree is broken in some other way", - fatal: true, - }), - }), - }, - () => - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isTrue(failure.fatal); - assert.include(failure.reason, "unsupported CPU architecture"); - assert.deepEqual(invalidatedRuntimeIds, []); - }), - ); - }); - it.effect("resolveWsl keeps WSL retryable when the mounted fallback fails transiently", () => { const stagedAppRoot = "/home/test/.t3/wsl-runtime/cache"; const invalidatedRuntimeIds: string[] = []; @@ -819,226 +727,6 @@ describe("DesktopBackendConfiguration", () => { ), ); - it.effect("resolvePrimary surfaces persisted backend observability endpoints", () => - withHarness( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - - yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), { - recursive: true, - }); - yield* fileSystem.writeFileString( - environment.serverSettingsPath, - yield* encodePersistedServerObservabilitySettingsDocument({ - observability: { - otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", - otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", - otlpLogsUrl: " http://127.0.0.1:4318/v1/logs ", - }, - }), - ); - - const config = yield* configuration.resolvePrimary; - assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); - assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); - assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); - }), - ), - ); - - it.effect("resolvePrimary omits backend observability endpoints when settings are missing", () => - withHarness( - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolvePrimary; - - assert.isUndefined(config.bootstrap.otlpTracesUrl); - assert.isUndefined(config.bootstrap.otlpMetricsUrl); - assert.isUndefined(config.bootstrap.otlpLogsUrl); - }), - ), - ); - - it.effect("resolveWsl carries environment-configured observability endpoints", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); - - assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); - assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); - assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), - getDistroIp: () => Option.some("172.27.0.99"), - }), - ), - Layer.provideMerge( - layerEnvironment(baseDir, { - platform: "win32", - otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", - otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", - otlpLogsUrl: " http://127.0.0.1:4318/v1/logs ", - }), - ), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("environment observability endpoints win over the persisted settings file", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - - yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), { - recursive: true, - }); - yield* fileSystem.writeFileString( - environment.serverSettingsPath, - yield* encodePersistedServerObservabilitySettingsDocument({ - observability: { - otlpTracesUrl: "http://persisted:4318/v1/traces", - otlpMetricsUrl: "http://persisted:4318/v1/metrics", - otlpLogsUrl: "http://persisted:4318/v1/logs", - }, - }), - ); - - const config = yield* configuration.resolvePrimary; - assert.equal(config.bootstrap.otlpLogsUrl, "http://env:4318/v1/logs"); - assert.equal(config.bootstrap.otlpTracesUrl, "http://persisted:4318/v1/traces"); - assert.equal(config.bootstrap.otlpMetricsUrl, "http://persisted:4318/v1/metrics"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest()), - Layer.provideMerge( - layerEnvironment(baseDir, { otlpLogsUrl: "http://env:4318/v1/logs" }), - ), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("logs structured context when persisted observability settings cannot be read", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - const settingsPath = path.join(baseDir, "userdata", "settings.json"); - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: settingsPath, - }); - const messages: Array = []; - const logger = Logger.make(({ message }) => { - messages.push(message); - }); - const layerFailingFileSystem = Layer.succeed( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.fail(cause), - }), - ); - - const config = yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - return yield* configuration.resolvePrimary; - }).pipe( - Effect.provide( - Layer.mergeAll( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest()), - Layer.provideMerge(layerEnvironment(baseDir)), - Layer.provideMerge(layerFailingFileSystem), - ), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); - - assert.isUndefined(config.bootstrap.otlpTracesUrl); - assert.isUndefined(config.bootstrap.otlpMetricsUrl); - assert.isUndefined(config.bootstrap.otlpLogsUrl); - - const error = messages - .flatMap((message) => (Array.isArray(message) ? message : [message])) - .find(isDesktopBackendObservabilitySettingsReadError); - assert.isDefined(error); - assert.equal(error.settingsPath, settingsPath); - assert.equal(error.cause, cause); - assert.equal( - error.message, - `Failed to read persisted backend observability settings at ${settingsPath}.`, - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("resolvePrimary captures backend output in dev so child logs can be persisted", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolvePrimary; - assert.equal(config.captureOutput, true); - assert.notInclude(config.args, "--require"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest()), - Layer.provideMerge( - layerEnvironment(baseDir, { - isPackaged: false, - devServerUrl: "http://127.0.0.1:5733", - }), - ), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("resolveWsl carries the kill switch into the distro", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1087,71 +775,6 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect( - "resolveWsl carries the standard OTLP endpoint, headers, and protocol into the distro", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - const standard = { - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com:4318/base?api_key=secret", - OTEL_EXPORTER_OTLP_LOGS_HEADERS: "authorization=Bearer%20token", - T3CODE_OTLP_TRACES_URL: "http://t3.example.com:4318/v1/traces", - }; - const previousWslEnv = process.env.WSLENV; - const ambientOtel = Object.entries(process.env).filter( - ([name]) => name.startsWith("OTEL_") || name.startsWith("T3CODE_OTLP_"), - ); - try { - for (const [name] of ambientOtel) delete process.env[name]; - delete process.env.WSLENV; - Object.assign(process.env, standard); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); - - assert.equal( - config.env.OTEL_EXPORTER_OTLP_ENDPOINT, - "https://collector.example.com:4318/base?api_key=secret", - ); - assert.equal( - config.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS, - "authorization=Bearer%20token", - ); - const wslEnv = (config.env.WSLENV ?? "").split(":"); - assert.include(wslEnv, "OTEL_EXPORTER_OTLP_ENDPOINT"); - assert.include(wslEnv, "OTEL_EXPORTER_OTLP_LOGS_HEADERS"); - assert.equal(config.env.T3CODE_OTLP_TRACES_URL, "http://t3.example.com:4318/v1/traces"); - assert.include(wslEnv, "T3CODE_OTLP_TRACES_URL"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), - getDistroIp: () => Option.some("172.27.0.99"), - }), - ), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - } finally { - for (const name of Object.keys(standard)) delete process.env[name]; - restoreEnv("WSLENV", previousWslEnv); - for (const [name, value] of ambientOtel) restoreEnv(name, value); - } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1299,42 +922,6 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("resolveWsl keeps a transient distro-list failure retryable", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: "Ubuntu" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isFalse(failure.fatal); - assert.equal(failure.retryLimit, 12); - assert.include(failure.reason, "timed out"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - distroListError: new DesktopWslEnvironment.DesktopWslDistroListError({ - reason: "wsl.exe --list --verbose timed out", - }), - }), - ), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("resolveWsl marks a missing packaged server entry as fatal", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1368,113 +955,6 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("resolveWsl surfaces sidecar extraction failures through typed preflight", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: "Ubuntu" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isFalse(failure.fatal); - assert.equal(failure.retryLimit, 12); - assert.include(failure.reason, "could not be extracted"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge( - DesktopWslServerTree.layerTest({ - result: { - ok: false, - reason: "WSL server files could not be extracted", - fatal: false, - }, - }), - ), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], - }), - ), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("resolveWsl marks a missing selected distro as a fatal preflight failure", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: "Removed-Distro" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isTrue(failure.fatal); - assert.include(failure.reason, "Removed-Distro"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], - }), - ), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("resolvePrimaryLabel reports the WSL distro when wsl-only and WSL is available", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const label = yield* configuration.resolvePrimaryLabel; - assert.equal(label, "WSL (Ubuntu)"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: true, - wslOnly: true, - wslDistro: "Ubuntu", - }), - ), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("prefers the external packaged resource monitor over the copy inside the asar", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1524,93 +1004,6 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("prefers the release resource monitor when both development builds exist", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - const dirname = path.join(baseDir, "apps/desktop/src"); - const releaseMonitorPath = path.join( - baseDir, - "native/resource-monitor/target/release/t3-resource-monitor", - ); - const debugMonitorPath = path.join( - baseDir, - "native/resource-monitor/target/debug/t3-resource-monitor", - ); - yield* fileSystem.makeDirectory(path.dirname(releaseMonitorPath), { recursive: true }); - yield* fileSystem.makeDirectory(path.dirname(debugMonitorPath), { recursive: true }); - yield* fileSystem.writeFileString(releaseMonitorPath, "release"); - yield* fileSystem.writeFileString(debugMonitorPath, "debug"); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolvePrimary; - assert.equal(config.bootstrap.resourceMonitorPath, releaseMonitorPath); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest()), - Layer.provideMerge( - layerEnvironment(baseDir, { - dirname, - devServerUrl: "http://127.0.0.1:5733", - isPackaged: false, - }), - ), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("resolvePrimaryLabel reports the local environment on non-Windows platforms", () => - withHarness( - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const label = yield* configuration.resolvePrimaryLabel; - assert.equal(label, "Local environment"); - }), - ), - ); - - it.effect("resolvePrimaryLabel reports Windows when wsl-only but WSL is unavailable", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const label = yield* configuration.resolvePrimaryLabel; - assert.equal(label, "Windows"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: true, - wslOnly: true, - wslDistro: "Ubuntu", - }), - ), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it("resolvePrimaryLabel is runSync-safe against the real WSL availability probe", async () => { // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- This test intentionally replicates the sync IPC handler's runSync path to catch a regression to async-only resolution; it.effect would mask it. const runtime = ManagedRuntime.make( diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 0bc1b998ede0..eadd741e1330 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -386,7 +386,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f : ({ ok: true, windowsEntryPath, linuxAppRoot: mountedAppRoot.value } as const); }); - let stagedFailure: { readonly runtimeId: string; readonly reason: string } | undefined; + let stagedFailure: { readonly runtimeId?: string; readonly reason: string } | undefined; const failedStaged = (failure: { readonly reason: string }) => ({ _tag: "Failed", @@ -417,6 +417,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f ); stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, reason: stagedProbe.reason }; } else { + stagedFailure = { reason: runtime.reason }; yield* Effect.logWarning( "Could not stage the WSL runtime; launching from the mounted server tree instead.", { reason: runtime.reason }, @@ -446,7 +447,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f : failedNodePty(nodePtyResult); } - if (stagedFailure) { + if (stagedFailure?.runtimeId) { yield* wslEnv.invalidateRuntime(runningDistro, stagedFailure.runtimeId); } @@ -882,8 +883,9 @@ export const make = Effect.gen(function* () { } return distro ? `WSL (${distro})` : "WSL"; }).pipe(Effect.withSpan("desktop.backendConfiguration.resolvePrimaryLabel")), - resolveWsl: (input) => - Effect.gen(function* () { + // The span options depend on each request's port and distro. + resolveWsl: Effect.fnUntraced( + function* (input) { const shared = yield* sharedInputs; return yield* resolveWslStartConfig({ ...shared, ...input, publicUrl: undefined }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), @@ -891,11 +893,14 @@ export const make = Effect.gen(function* () { Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree), Effect.provideService(FileSystem.FileSystem, fileSystem), ); - }).pipe( - Effect.withSpan("desktop.backendConfiguration.resolveWsl", { - attributes: { port: input.port, distro: input.distro ?? null }, - }), - ), + }, + (effect, input) => + effect.pipe( + Effect.withSpan("desktop.backendConfiguration.resolveWsl", { + attributes: { port: input.port, distro: input.distro ?? null }, + }), + ), + ), }); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 8cc1a9fc6433..7b15346a754e 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -24,8 +24,6 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; -import * as DesktopApp from "../app/DesktopApp.ts"; -import * as DesktopBackendPool from "./DesktopBackendPool.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; @@ -34,7 +32,6 @@ import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; const decodeDesktopBackendBootstrap = Schema.decodeEffect( Schema.fromJsonString(DesktopBackendBootstrap), ); -const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError); const encodeDesktopTelemetryControl = Schema.encodeSync( Schema.fromJsonString(DesktopTelemetryControlMessage), ); @@ -274,258 +271,27 @@ describe("DesktopBackendManager", () => { ), ); - it.effect("preserves the readiness timeout cause and process context", () => - Effect.gen(function* () { - const requested = yield* Deferred.make(); - const layer = Layer.merge( - TestClock.layer(), - layerHttpClient((request) => - Deferred.succeed(requested, request).pipe(Effect.andThen(Effect.never)), - ), - ); - - yield* Effect.gen(function* () { - const readiness = yield* DesktopBackendManager.waitForHttpReady({ - executablePath: baseConfig.executablePath, - entryPath: baseConfig.entryPath, - cwd: baseConfig.cwd, - httpBaseUrl: baseConfig.httpBaseUrl, - timeout: Duration.millis(50), - }).pipe(Effect.flip, Effect.forkChild); - - const request = yield* Deferred.await(requested); - assert.equal(request.url, "http://127.0.0.1:3773/.well-known/t3/environment"); - - yield* TestClock.adjust(Duration.millis(50)); - const error = yield* Fiber.join(readiness); - - assert.instanceOf(error, DesktopBackendManager.BackendReadinessTimeoutError); - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.equal(error.readinessUrl.href, "http://127.0.0.1:3773/.well-known/t3/environment"); - assert.equal(error.timeoutMs, 50); - assert.isDefined(error.cause); - assert.equal( - error.message, - "Timed out after 50ms waiting for desktop backend readiness at http://127.0.0.1:3773/.well-known/t3/environment.", - ); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect("reports bootstrap encoding failures with stable process context", () => - Effect.gen(function* () { - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), - ); - const error = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - desktopTelemetryStream: Stream.empty, - bootstrap: { - ...baseConfig.bootstrap, - port: 0, - }, - }).pipe( - Effect.flip, - Effect.scoped, - Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)), - ); - - if (error._tag !== "BackendProcessBootstrapEncodeError") { - return assert.fail(`Expected bootstrap encode error, received ${error._tag}`); - } - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.isDefined(error.cause); - assert.equal( - error.message, - "Failed to encode the desktop backend bootstrap payload for /server/bin.mjs.", - ); - assert.isTrue(isBackendProcessError(error)); - }), - ); - - it.effect("preserves spawn failures without deriving their message from the cause", () => - Effect.gen(function* () { - const spawnCause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "ChildProcessSpawner", - method: "spawn", - pathOrDescriptor: baseConfig.executablePath, - description: "low-level detail that must not become the public message", - }); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.fail(spawnCause)), - ); - const error = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - desktopTelemetryStream: Stream.empty, - }).pipe( - Effect.flip, - Effect.scoped, - Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)), - ); - - if (error._tag !== "BackendProcessSpawnError") { - return assert.fail(`Expected backend spawn error, received ${error._tag}`); - } - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.strictEqual(error.cause, spawnCause); - assert.equal( - error.message, - "Failed to spawn desktop backend entry /server/bin.mjs with /electron.", - ); - assert.notInclude(error.message, spawnCause.message); - assert.isTrue(isBackendProcessError(error)); - }), - ); - - it.effect("preserves exit-status failures without copying their detail into the message", () => + it.effect("reports a SIGKILL exit without an exit code", () => Effect.gen(function* () { const exitCause = PlatformError.systemError({ - _tag: "PermissionDenied", + _tag: "Unknown", module: "ChildProcess", method: "exitCode", - description: "exit-status-secret-sentinel", - }); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - exitCode: Effect.fail(exitCause), - }), - ), - ), - ); - const error = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - desktopTelemetryStream: Stream.empty, - }).pipe( - Effect.flip, - Effect.scoped, - Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)), - ); - - if (error._tag !== "BackendProcessExitStatusError") { - return assert.fail(`Expected backend exit-status error, received ${error._tag}`); - } - assert.equal(error.pid, 123); - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.strictEqual(error.cause, exitCause); - assert.equal(error.message, "Failed to read the exit status of desktop backend process 123."); - assert.notInclude(error.message, "exit-status-secret-sentinel"); - assert.isTrue(isBackendProcessError(error)); - }), - ); - - it.effect("reports output stream failures with process and stream context", () => - Effect.gen(function* () { - const outputCause = PlatformError.systemError({ - _tag: "BadResource", - module: "ChildProcess", - method: "stdout", - description: "output-stream-secret-sentinel", + cause: new Error("Process interrupted due to receipt of signal: 'SIGKILL'"), }); - const reported = yield* Deferred.make(); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - stdout: Stream.fail(outputCause), - exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }), - ), - ), - ); - - const exit = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - desktopTelemetryStream: Stream.empty, - onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), - }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient))); - const error = yield* Deferred.await(reported); - - assert.equal(exit.code.pipe(Option.getOrUndefined), 0); - if (error._tag !== "BackendProcessOutputReadError") { - return assert.fail(`Expected output read error, received ${error._tag}`); - } - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.equal(error.pid, 123); - assert.equal(error.streamName, "stdout"); - assert.strictEqual(error.cause, outputCause); - assert.equal(error.message, "Failed to read stdout from desktop backend process 123."); - assert.notInclude(error.message, "output-stream-secret-sentinel"); - }), - ); - - it.effect("reports output handler failures separately from stream read failures", () => - Effect.gen(function* () { - const chunk = new TextEncoder().encode("backend output"); - const nextChunk = new TextEncoder().encode("still draining"); - const outputCause = new Error("output-handler-secret-sentinel"); - const reported = yield* Deferred.make(); - const drained = yield* Deferred.make(); - let outputCount = 0; const layerSpawner = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - stdout: Stream.make(chunk, nextChunk), - exitCode: Deferred.await(drained).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }), - ), + Effect.succeed(makeProcess({ exitCode: Effect.fail(exitCause) })), ), ); const exit = yield* DesktopBackendManager.runBackendProcess({ ...baseConfig, desktopTelemetryStream: Stream.empty, - onOutput: () => { - outputCount += 1; - return outputCount === 1 - ? Effect.fail(outputCause) - : Deferred.succeed(drained, void 0).pipe(Effect.asVoid); - }, - onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient))); - const error = yield* Deferred.await(reported); - assert.equal(exit.code.pipe(Option.getOrUndefined), 0); - if (error._tag !== "BackendProcessOutputHandlingError") { - return assert.fail(`Expected output handling error, received ${error._tag}`); - } - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.equal(error.pid, 123); - assert.equal(error.streamName, "stdout"); - assert.equal(error.chunkByteLength, chunk.byteLength); - assert.strictEqual(error.cause, outputCause); - assert.equal( - error.message, - `Failed to handle ${chunk.byteLength} bytes from stdout of desktop backend process 123.`, - ); - assert.notInclude(error.message, "output-handler-secret-sentinel"); - assert.equal(outputCount, 2); + assert.deepEqual(exit, { code: Option.none(), reason: "killed by SIGKILL" }); }), ); @@ -607,195 +373,6 @@ describe("DesktopBackendManager", () => { ), ); - it.effect("drains trailing child output before persisting an unexpected exit", () => - Effect.scoped( - Effect.gen(function* () { - const persistedOutput = yield* Deferred.make>(); - const outputDrainStarted = yield* Deferred.make(); - const outputChunks = yield* Ref.make>([]); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - stdout: Stream.fromEffect( - Deferred.succeed(outputDrainStarted, void 0).pipe( - Effect.andThen(Effect.sleep(Duration.seconds(1))), - Effect.as(new TextEncoder().encode("trailing output\n")), - ), - ), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), - }), - ), - ), - ); - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - httpClientLayer: layerHttpClient(() => Effect.never), - backendOutputLog: { - writeOutputChunk: (_streamName, chunk) => - Ref.update(outputChunks, (current) => [...current, new TextDecoder().decode(chunk)]), - persistFailure: () => - Ref.get(outputChunks).pipe( - Effect.flatMap((chunks) => Deferred.succeed(persistedOutput, chunks)), - Effect.asVoid, - ), - }, - }); - - yield* instance.start; - yield* Deferred.await(outputDrainStarted); - yield* TestClock.adjust(Duration.seconds(1)); - - assert.deepEqual(yield* Deferred.await(persistedOutput), ["trailing output\n"]); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("retries HTTP readiness before reporting the backend ready", () => - Effect.scoped( - Effect.gen(function* () { - const requestUrls: Array = []; - const prunedRuntimes: Array<[string | null, string]> = []; - const statuses = [503, 200]; - let readyCount = 0; - const firstRequest = yield* Deferred.make(); - const backendReady = yield* Deferred.make(); - const processExit = yield* Deferred.make(); - const pruneComplete = yield* Deferred.make(); - const exited = yield* Queue.unbounded(); - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - exitCode: Deferred.await(processExit).pipe( - Effect.as(ChildProcessSpawner.ExitCode(0)), - ), - }), - ), - ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - config: { - ...baseConfig, - runningDistro: "Ubuntu", - wslRuntimeId: "1.2.3-x64", - }, - pruneRuntimes: (distro, runtimeId) => - Effect.sync(() => { - prunedRuntimes.push([distro, runtimeId]); - }).pipe(Effect.andThen(Deferred.succeed(pruneComplete, void 0)), Effect.asVoid), - httpClientLayer: layerHttpClient((request) => - Effect.gen(function* () { - const status = statuses.shift(); - assert.isDefined(status); - requestUrls.push(request.url); - yield* Deferred.succeed(firstRequest, void 0); - return responseForRequest(request, status); - }), - ), - onReady: Effect.sync(() => { - readyCount += 1; - }).pipe(Effect.andThen(Deferred.succeed(backendReady, void 0)), Effect.asVoid), - backendOutputLog: { - persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), - }, - }); - - yield* instance.start; - yield* Deferred.await(firstRequest); - - assert.equal(readyCount, 0); - assert.deepEqual(prunedRuntimes, []); - assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/.well-known/t3/environment"]); - - yield* TestClock.adjust(Duration.millis(100)); - yield* Deferred.await(backendReady); - yield* Deferred.await(pruneComplete); - yield* Deferred.succeed(processExit, void 0); - yield* Queue.take(exited); - - assert.equal(readyCount, 1); - assert.deepEqual(prunedRuntimes, [["Ubuntu", "1.2.3-x64"]]); - assert.deepEqual(requestUrls, [ - "http://127.0.0.1:3773/.well-known/t3/environment", - "http://127.0.0.1:3773/.well-known/t3/environment", - ]); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect( - "re-probes readiness after the first budget expires while the backend is still alive", - () => - Effect.scoped( - Effect.gen(function* () { - const requestUrls: Array = []; - let requestCount = 0; - let readyCount = 0; - let readinessTimeoutCount = 0; - const firstProbe = yield* Deferred.make(); - const childExit = yield* Deferred.make(); - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - exitCode: Deferred.await(childExit).pipe( - Effect.as(ChildProcessSpawner.ExitCode(0)), - ), - }), - ), - ), - ); - - const layerHttp = layerHttpClient((request) => - Effect.gen(function* () { - requestCount += 1; - requestUrls.push(request.url); - yield* Deferred.succeed(firstProbe, void 0); - return responseForRequest(request, requestCount <= 2 ? 503 : 200); - }), - ); - - const runFiber = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - desktopTelemetryStream: Stream.empty, - readinessTimeout: Duration.millis(50), - onReady: () => - Effect.sync(() => { - readyCount += 1; - }), - onReadinessFailure: () => - Effect.sync(() => { - readinessTimeoutCount += 1; - }), - }).pipe(Effect.provide(Layer.merge(layerSpawner, layerHttp)), Effect.forkChild); - - yield* Deferred.await(firstProbe); - assert.equal(readyCount, 0); - assert.equal(readinessTimeoutCount, 0); - - yield* TestClock.adjust(Duration.millis(50)); - assert.equal(readinessTimeoutCount, 1); - assert.equal(readyCount, 0); - - yield* TestClock.adjust(Duration.millis(100)); - assert.equal(readinessTimeoutCount, 2); - assert.equal(readyCount, 1); - assert.equal(requestUrls.length, 3); - - yield* Deferred.succeed(childExit, void 0); - assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 0); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - it.effect("starts the configured backend and closes the scoped process on stop", () => Effect.scoped( Effect.gen(function* () { @@ -966,254 +543,27 @@ describe("DesktopBackendManager", () => { ), ); - it.effect("retries config resolution after a start request during stop teardown", () => + it.effect("reports state-directory ownership once without scheduling another backend", () => Effect.scoped( Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - const teardownStarted = yield* Deferred.make(); - const finishTeardown = yield* Deferred.make(); - const configAttempts = yield* Ref.make(0); let startCount = 0; - - const configFailure = PlatformError.systemError({ - _tag: "Unknown", - module: "DesktopBackendManager", - method: "configResolve", - description: "transient configuration failure", - }); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - const closed = yield* Deferred.make(); - startCount += 1; - yield* Queue.offer(starts, startCount); - if (startCount === 1) { - yield* Scope.addFinalizer( - scope, - Deferred.succeed(teardownStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishTeardown)), - Effect.andThen(Deferred.succeed(closed, undefined)), - Effect.asVoid, + const refused = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Effect.succeed( + ChildProcessSpawner.ExitCode(SERVER_EXIT_CODE_STATE_DIR_OWNED), ), - ); - } else { - yield* Scope.addFinalizer( - scope, - Deferred.succeed(closed, undefined).pipe(Effect.asVoid), - ); - } - return makeProcess({ - exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - ), - ); - const configResolve = Ref.updateAndGet(configAttempts, (attempt) => attempt + 1).pipe( - Effect.flatMap((attempt) => - attempt === 2 ? Effect.fail(configFailure) : Effect.succeed(baseConfig), + }); + }), + ), ), - ); - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - configResolve, httpClientLayer: layerHttpClient(() => Effect.never), - }); - - yield* instance.start; - assert.equal(yield* Queue.take(starts), 1); - - const stopFiber = yield* instance.stop().pipe(Effect.forkChild); - yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); - yield* instance.start; - yield* Deferred.succeed(finishTeardown, undefined); - yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); - - const pendingRestart = yield* instance.snapshot; - assert.equal(pendingRestart.desiredRunning, true); - assert.equal(pendingRestart.restartScheduled, true); - - yield* TestClock.adjust(Duration.seconds(2)); - - assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2); - assert.equal(yield* Ref.get(configAttempts), 3); - assert.equal((yield* instance.snapshot).desiredRunning, true); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("keeps a timed-out run active until its process exits", () => - Effect.scoped( - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - const teardownStarted = yield* Deferred.make(); - const finishTeardown = yield* Deferred.make(); - let startCount = 0; - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - const closed = yield* Deferred.make(); - startCount += 1; - yield* Queue.offer(starts, startCount); - if (startCount === 1) { - yield* Scope.addFinalizer( - scope, - Deferred.succeed(teardownStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishTeardown)), - Effect.andThen(Deferred.succeed(closed, undefined)), - Effect.asVoid, - ), - ); - } else { - yield* Scope.addFinalizer( - scope, - Deferred.succeed(closed, undefined).pipe(Effect.asVoid), - ); - } - return makeProcess({ - exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - httpClientLayer: layerHttpClient(() => Effect.never), - }); - - yield* instance.start; - assert.equal(yield* Queue.take(starts), 1); - - const stopFiber = yield* instance - .stop({ timeout: Duration.millis(100) }) - .pipe(Effect.forkChild); - yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); - yield* instance.start; - yield* TestClock.adjust(Duration.millis(100)); - yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); - - assert.equal(startCount, 1); - const timedOut = yield* instance.snapshot; - assert.equal(timedOut.desiredRunning, true); - assert.deepEqual(timedOut.activePid, Option.some(123)); - - yield* Deferred.succeed(finishTeardown, undefined); - yield* TestClock.adjust(Duration.millis(500)); - - assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("does not notify shutdown before the first start has prior state", () => - Effect.scoped( - Effect.gen(function* () { - let shutdownCount = 0; - const closed = yield* Deferred.make(); - const startedPids = yield* Queue.unbounded(); - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - yield* Queue.offer(startedPids, 123); - const close = Deferred.succeed(closed, void 0).pipe(Effect.asVoid); - return makeProcess({ - exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - kill: () => close, - }); - }), - ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - httpClientLayer: layerHttpClient(() => Effect.never), - onShutdown: Effect.sync(() => { - shutdownCount += 1; - }), - }); - - yield* instance.start; - assert.equal(yield* Queue.take(startedPids), 123); - assert.equal(shutdownCount, 0); - }), - ), - ); - - it.effect("restarts an unexpectedly exited backend with the Effect clock", () => - Effect.scoped( - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - const failures = yield* Queue.unbounded(); - let startCount = 0; - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.sync(() => { - startCount += 1; - return makeProcess({ - exitCode: Queue.offer(starts, startCount).pipe( - Effect.as(ChildProcessSpawner.ExitCode(1)), - ), - }); - }), - ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - httpClientLayer: layerHttpClient(() => Effect.never), - backendOutputLog: { - persistFailure: ({ details }) => Queue.offer(failures, details).pipe(Effect.asVoid), - }, - }); - - yield* instance.start; - - assert.equal(yield* Queue.take(starts), 1); - assert.equal(yield* Queue.take(failures), "pid=123 code=1"); - - yield* TestClock.adjust(Duration.millis(499)); - assert.equal(yield* Queue.size(starts), 0); - yield* TestClock.adjust(Duration.millis(1)); - assert.equal(yield* Queue.take(starts), 2); - - yield* TestClock.adjust(Duration.millis(999)); - assert.equal(yield* Queue.size(starts), 0); - yield* TestClock.adjust(Duration.millis(1)); - assert.equal(yield* Queue.take(starts), 3); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("reports state-directory ownership once without scheduling another backend", () => - Effect.scoped( - Effect.gen(function* () { - let startCount = 0; - const refused = yield* Deferred.make(); - const instance = yield* makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.sync(() => { - startCount += 1; - return makeProcess({ - exitCode: Effect.succeed( - ChildProcessSpawner.ExitCode(SERVER_EXIT_CODE_STATE_DIR_OWNED), - ), - }); - }), - ), - ), - httpClientLayer: layerHttpClient(() => Effect.never), - onStateDirOwned: Deferred.succeed(refused, undefined).pipe(Effect.asVoid), + onStateDirOwned: Deferred.succeed(refused, undefined).pipe(Effect.asVoid), }); yield* instance.start; yield* Deferred.await(refused); @@ -1225,361 +575,4 @@ describe("DesktopBackendManager", () => { }).pipe(Effect.provide(TestClock.layer())), ), ); - - it.effect("does not notify shutdown when a scheduled restart starts from non-ready state", () => - Effect.scoped( - Effect.gen(function* () { - let shutdownCount = 0; - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - config: { - ...baseConfig, - preflightFailure: Option.some({ reason: "preflight failed", fatal: false }), - }, - onShutdown: Effect.sync(() => { - shutdownCount += 1; - }), - }); - - yield* instance.start; - assert.equal(shutdownCount, 0); - - yield* TestClock.adjust(Duration.millis(500)); - assert.equal(shutdownCount, 0); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("surfaces a fatal preflight failure once and stops looping after the cap", () => - Effect.scoped( - Effect.gen(function* () { - const failures: string[] = []; - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - config: { - ...baseConfig, - preflightFailure: Option.some({ reason: "Node.js not found", fatal: true }), - }, - onPreflightFailed: (failure) => - Effect.sync(() => { - failures.push(failure.reason); - }).pipe(Effect.as(false)), - }); - - yield* instance.start; - assert.deepEqual(failures, []); - - yield* TestClock.adjust(Duration.millis(500)); - yield* TestClock.adjust(Duration.seconds(1)); - yield* TestClock.adjust(Duration.seconds(2)); - yield* TestClock.adjust(Duration.seconds(4)); - assert.deepEqual(failures, ["Node.js not found"]); - - yield* TestClock.adjust(Duration.seconds(8)); - yield* TestClock.adjust(Duration.seconds(30)); - assert.deepEqual(failures, ["Node.js not found"]); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("can be started again after a fatal preflight cap once config recovers", () => - Effect.scoped( - Effect.gen(function* () { - const failing = yield* Ref.make(true); - const starts = yield* Queue.unbounded(); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Queue.offer(starts, 123).pipe( - Effect.as( - makeProcess({ - exitCode: Effect.never, - }), - ), - ), - ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - configResolve: Ref.get(failing).pipe( - Effect.map((isFailing) => - isFailing - ? { - ...baseConfig, - preflightFailure: Option.some({ - reason: "Node.js not found", - fatal: true, - }), - } - : baseConfig, - ), - ), - }); - - yield* instance.start; - yield* TestClock.adjust(Duration.millis(500)); - yield* TestClock.adjust(Duration.seconds(1)); - yield* TestClock.adjust(Duration.seconds(2)); - yield* TestClock.adjust(Duration.seconds(4)); - yield* TestClock.adjust(Duration.seconds(8)); - - const parked = yield* instance.snapshot; - assert.equal(parked.desiredRunning, false); - assert.equal(parked.ready, false); - assert.isTrue(Option.isNone(parked.activePid)); - assert.equal(parked.restartScheduled, false); - assert.equal(yield* Queue.size(starts), 0); - - yield* Ref.set(failing, false); - yield* instance.start; - - assert.equal(yield* Queue.take(starts), 123); - const running = yield* instance.snapshot; - assert.equal(running.desiredRunning, true); - assert.deepEqual(running.activePid, Option.some(123)); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("keeps retrying a transient (non-fatal) preflight failure without surfacing", () => - Effect.scoped( - Effect.gen(function* () { - const failures: string[] = []; - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - config: { - ...baseConfig, - preflightFailure: Option.some({ reason: "wslpath conversion failed", fatal: false }), - }, - onPreflightFailed: (failure) => - Effect.sync(() => { - failures.push(failure.reason); - }).pipe(Effect.as(false)), - }); - - yield* instance.start; - - yield* TestClock.adjust(Duration.minutes(2)); - assert.deepEqual(failures, []); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("surfaces a bounded transient preflight failure after its retry limit", () => - Effect.scoped( - Effect.gen(function* () { - const failures: string[] = []; - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - config: { - ...baseConfig, - preflightFailure: Option.some({ - reason: "WSL toolchain probe timed out", - fatal: false, - retryLimit: 3, - }), - }, - onPreflightFailed: (failure) => - Effect.sync(() => { - failures.push(failure.reason); - }).pipe(Effect.as(false)), - }); - - yield* instance.start; - yield* TestClock.adjust(Duration.millis(500)); - assert.deepEqual(failures, []); - - yield* TestClock.adjust(Duration.seconds(1)); - assert.deepEqual(failures, ["WSL toolchain probe timed out"]); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("cancels a scheduled restart when start is requested manually", () => - Effect.scoped( - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - const secondClosed = yield* Deferred.make(); - let startCount = 0; - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - startCount += 1; - yield* Queue.offer(starts, startCount); - - if (startCount === 1) { - return makeProcess({ - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), - }); - } - - const scope = yield* Scope.Scope; - const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid); - yield* Scope.addFinalizer(scope, close); - return makeProcess({ - exitCode: Deferred.await(secondClosed).pipe( - Effect.as(ChildProcessSpawner.ExitCode(0)), - ), - kill: () => close, - }); - }), - ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - httpClientLayer: layerHttpClient(() => Effect.never), - }); - - yield* instance.start; - - assert.equal(yield* Queue.take(starts), 1); - let restartScheduled = false; - while (!restartScheduled) { - restartScheduled = (yield* instance.snapshot).restartScheduled; - if (!restartScheduled) { - yield* Effect.yieldNow; - } - } - - yield* instance.start; - assert.equal(yield* Queue.take(starts), 2); - - yield* instance.stop(); - yield* TestClock.adjust(Duration.millis(500)); - - assert.equal(yield* Queue.size(starts), 0); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("does not restart after stop cancels a scheduled restart", () => - Effect.scoped( - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - let startCount = 0; - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.sync(() => { - startCount += 1; - return makeProcess({ - exitCode: Queue.offer(starts, startCount).pipe( - Effect.as(ChildProcessSpawner.ExitCode(1)), - ), - }); - }), - ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - httpClientLayer: layerHttpClient(() => Effect.never), - }); - - yield* instance.start; - assert.equal(yield* Queue.take(starts), 1); - - let restartScheduled = false; - while (!restartScheduled) { - restartScheduled = (yield* instance.snapshot).restartScheduled; - if (!restartScheduled) { - yield* Effect.yieldNow; - } - } - - yield* instance.stop(); - yield* TestClock.adjust(Duration.millis(500)); - - assert.equal(yield* Queue.size(starts), 0); - assert.equal((yield* instance.snapshot).desiredRunning, false); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("stopAllPoolInstances bounds the quit finalizer when backends hang", () => - Effect.scoped( - Effect.gen(function* () { - const teardownStarted = yield* Queue.unbounded(); - const teardownFinished = yield* Queue.unbounded(); - const allowTeardown = yield* Deferred.make(); - - const makeInstance = (name: string) => - makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - yield* Scope.addFinalizer( - scope, - Queue.offer(teardownStarted, name).pipe( - Effect.andThen(Deferred.await(allowTeardown)), - Effect.andThen(Queue.offer(teardownFinished, name)), - Effect.asVoid, - ), - ); - return makeProcess({ exitCode: Effect.never }); - }), - ), - ), - httpClientLayer: layerHttpClient(() => Effect.never), - }); - - const instance1 = yield* makeInstance("instance1"); - const instance2 = yield* makeInstance("instance2"); - - yield* instance1.start; - yield* instance2.start; - - const layerMockPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { - list: Effect.succeed([instance1, instance2]), - get: () => Effect.succeedNone, - primary: Effect.die(new Error("primary not implemented")), - register: () => Effect.die(new Error("register not implemented")), - unregister: () => Effect.die(new Error("unregister not implemented")), - }); - - const quitFiber = yield* Effect.scoped( - Effect.addFinalizer(() => DesktopApp.stopAllPoolInstances()), - ).pipe(Effect.provide(layerMockPool), Effect.forkChild); - - const started = yield* Queue.takeN(teardownStarted, 2); - assert.deepEqual(started.toSorted(), ["instance1", "instance2"]); - - yield* TestClock.adjust(Duration.seconds(5)); - yield* Fiber.join(quitFiber); - assert.equal(yield* Queue.size(teardownFinished), 0); - - yield* Deferred.succeed(allowTeardown, undefined); - const finished = yield* Queue.takeN(teardownFinished, 2); - assert.deepEqual(finished.toSorted(), ["instance1", "instance2"]); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 8d2a3f1c4912..b4057f4d0194 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -312,7 +312,7 @@ const closeRun = ( ); }; -export const waitForHttpReady = ( +const waitForHttpReady = ( options: BackendProcessContext & { readonly timeout: Duration.Duration }, ): Effect.Effect => { const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl); @@ -568,8 +568,22 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped); const exit = yield* handle.exitCode.pipe( - Effect.mapError( - (cause) => + Effect.map((exitCode) => ({ + code: Option.some(exitCode), + reason: `code=${exitCode}`, + })), + Effect.catch((cause) => { + // Effect's Node spawner exposes signal exits only through this cause message. + const signal = + cause.cause instanceof Error + ? /^Process interrupted due to receipt of signal: '(SIG[A-Z0-9]+)'$/.exec( + cause.cause.message, + )?.[1] + : undefined; + if (signal !== undefined) { + return Effect.succeed({ code: Option.none(), reason: `killed by ${signal}` }); + } + return Effect.fail( new BackendProcessExitStatusError({ executablePath: options.executablePath, entryPath: options.entryPath, @@ -578,7 +592,8 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( pid: Number(handle.pid), cause, }), - ), + ); + }), Effect.exit, ); yield* options.onExitObserved?.() ?? Effect.void; @@ -592,11 +607,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( if (Exit.isFailure(exit)) { return yield* Effect.failCause(exit.cause); } - const exitCode = exit.value; - return { - code: Option.some(exitCode), - reason: `code=${exitCode}`, - } satisfies BackendProcessExit; + return exit.value; }); export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( @@ -1103,20 +1114,25 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }); }); - const waitForReady = (timeout: Duration.Duration): Effect.Effect => - Effect.gen(function* () { + const waitForReady: (timeout: Duration.Duration) => Effect.Effect = Effect.fn( + "DesktopBackendManager.waitForReady", + )( + function* (_timeout: Duration.Duration) { const current = yield* Ref.get(state); if (!current.desiredRunning) return { done: true, ready: false }; return current.ready ? { done: true, ready: true } : { done: false, ready: false }; - }).pipe( - Effect.repeat({ - until: (status) => status.done, - schedule: Schedule.spaced(Duration.millis(100)), - }), - Effect.map((status) => status.ready), - Effect.timeoutOption(timeout), - Effect.map(Option.getOrElse(() => false)), - ); + }, + (effect, timeout) => + effect.pipe( + Effect.repeat({ + until: (status) => status.done, + schedule: Schedule.spaced(Duration.millis(100)), + }), + Effect.map((status) => status.ready), + Effect.timeoutOption(timeout), + Effect.map(Option.getOrElse(() => false)), + ), + ); yield* Effect.addFinalizer(() => stop()); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 72a05e9d657d..a8576a2b9e99 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -1,5 +1,4 @@ import { assert, describe, it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -19,29 +18,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; -import type { DesktopBackendSnapshot, DesktopBackendStartConfig } from "./DesktopBackendManager.ts"; - -function makeStubInstance( - id: DesktopBackendPool.BackendInstanceId, - label: string, -): DesktopBackendPool.DesktopBackendInstance { - const snapshot: DesktopBackendSnapshot = { - desiredRunning: false, - ready: false, - activePid: Option.none(), - restartAttempt: 0, - restartScheduled: false, - }; - return { - id, - label: Effect.succeed(label), - start: Effect.void, - stop: () => Effect.void, - currentConfig: Effect.succeed(Option.none()), - snapshot: Effect.succeed(snapshot), - waitForReady: (_timeout: Duration.Duration) => Effect.succeed(false), - }; -} +import type { DesktopBackendStartConfig } from "./DesktopBackendManager.ts"; function layerPool(labelRef: Ref.Ref): Layer.Layer { return DesktopBackendPool.layer.pipe( @@ -111,52 +88,6 @@ function layerPool(labelRef: Ref.Ref): Layer.Layer { - it.effect("layerTest exposes registered instances by id", () => - Effect.gen(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const fetchedPrimary = yield* pool.get(DesktopBackendPool.PRIMARY_INSTANCE_ID); - const fetchedWsl = yield* pool.get(DesktopBackendPool.BackendInstanceId("wsl:ubuntu")); - const fetchedMissing = yield* pool.get(DesktopBackendPool.BackendInstanceId("missing")); - const all = yield* pool.list; - const resolvedPrimary = yield* pool.primary; - - assert.equal(yield* Option.getOrThrow(fetchedPrimary).label, "Windows"); - assert.equal(yield* Option.getOrThrow(fetchedWsl).label, "WSL (Ubuntu)"); - assert.isTrue(Option.isNone(fetchedMissing)); - assert.lengthOf(all, 2); - assert.equal(resolvedPrimary.id, DesktopBackendPool.PRIMARY_INSTANCE_ID); - }).pipe( - Effect.provide( - DesktopBackendPool.layerTest([ - makeStubInstance(DesktopBackendPool.PRIMARY_INSTANCE_ID, "Windows"), - makeStubInstance(DesktopBackendPool.BackendInstanceId("wsl:ubuntu"), "WSL (Ubuntu)"), - ]), - ), - ), - ); - - it.effect("layerTest dies when no instances are supplied", () => - Effect.exit( - DesktopBackendPool.DesktopBackendPool.pipe(Effect.provide(DesktopBackendPool.layerTest([]))), - ).pipe(Effect.map((exit) => assert.equal(exit._tag, "Failure"))), - ); - - it.effect("resolves the primary label lazily after pool layer construction", () => - Effect.scoped( - Effect.gen(function* () { - const labelRef = yield* Ref.make("Windows"); - const pool = yield* DesktopBackendPool.DesktopBackendPool.pipe( - Effect.provide(layerPool(labelRef)), - ); - const primary = yield* pool.primary; - - yield* Ref.set(labelRef, "WSL (Ubuntu)"); - - assert.equal(yield* primary.label, "WSL (Ubuntu)"); - }), - ), - ); - it.effect("unregister stops the registered instance", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index a0e6d4377f2c..135df79c63ad 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -180,7 +180,7 @@ export const layer = Layer.effect( electronDialog .showErrorBox( "This T3 home is unavailable", - "Another T3 Code server, such as the background service, is using this data directory. Stop that server and reopen T3 Code, or set a separate T3CODE_HOME and pair with the running server.", + "Another T3 Code server, such as the background service, is using this data directory, or an older background service is set up for it. Stop that server or try `t3 update`. If the older launcher cannot update, run `t3 service uninstall` and optionally reinstall with `t3 service install` from a current CLI. Use `npx t3@latest` if `t3` is missing or outdated, and pass `--base-dir` with the service's T3 home to each command. Then reopen T3 Code. You can also set a separate T3CODE_HOME and pair with the running server.", ) .pipe(Effect.andThen(electronApp.quit)), }); @@ -249,61 +249,59 @@ export const layer = Layer.effect( ), ); - const unregister: DesktopBackendPool["Service"]["unregister"] = (id) => - Effect.gen(function* () { - if (id === DesktopBackendManager.PRIMARY_INSTANCE_ID) { - return yield* new DesktopBackendPoolCannotUnregisterPrimaryError(); - } - const done = yield* Deferred.make(); - const action = yield* SynchronizedRef.modifyEffect( - instancesRef, - ( - current, - ): Effect.Effect< - readonly [UnregisterAction, ReadonlyMap] - > => { - const entry = current.get(id); - if (entry === undefined) { - return Effect.succeed([{ _tag: "Absent" } as const, current] as const); - } - if (entry._tag === "Closing") { - return Effect.succeed([ - { _tag: "Wait", done: entry.done } as const, - current, - ] as const); - } - const next = new Map(current); - next.set(id, { _tag: "Closing", done }); - return Effect.succeed([ - { _tag: "Close", entry } as const, - next as ReadonlyMap, - ] as const); - }, - ); - - if (action._tag === "Absent") return; - if (action._tag === "Wait") { - yield* Deferred.await(action.done); - return; - } - - const finish = SynchronizedRef.modifyEffect(instancesRef, (current) => { - const closing = current.get(id); - if (closing?._tag !== "Closing" || closing.done !== done) { - return Effect.succeed([undefined, current] as const); + const unregister: DesktopBackendPool["Service"]["unregister"] = Effect.fn( + "DesktopBackendPool.unregister", + )(function* (id) { + if (id === DesktopBackendManager.PRIMARY_INSTANCE_ID) { + return yield* new DesktopBackendPoolCannotUnregisterPrimaryError(); + } + const done = yield* Deferred.make(); + const action = yield* SynchronizedRef.modifyEffect( + instancesRef, + ( + current, + ): Effect.Effect< + readonly [UnregisterAction, ReadonlyMap] + > => { + const entry = current.get(id); + if (entry === undefined) { + return Effect.succeed([{ _tag: "Absent" } as const, current] as const); + } + if (entry._tag === "Closing") { + return Effect.succeed([{ _tag: "Wait", done: entry.done } as const, current] as const); } const next = new Map(current); - next.delete(id); + next.set(id, { _tag: "Closing", done }); return Effect.succeed([ - undefined, + { _tag: "Close", entry } as const, next as ReadonlyMap, ] as const); - }).pipe(Effect.andThen(Deferred.succeed(done, undefined)), Effect.asVoid); - yield* Option.match(action.entry.scope, { - onNone: () => Effect.void, - onSome: (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore), - }).pipe(Effect.ensuring(finish)); - }); + }, + ); + + if (action._tag === "Absent") return; + if (action._tag === "Wait") { + yield* Deferred.await(action.done); + return; + } + + const finish = SynchronizedRef.modifyEffect(instancesRef, (current) => { + const closing = current.get(id); + if (closing?._tag !== "Closing" || closing.done !== done) { + return Effect.succeed([undefined, current] as const); + } + const next = new Map(current); + next.delete(id); + return Effect.succeed([ + undefined, + next as ReadonlyMap, + ] as const); + }).pipe(Effect.andThen(Deferred.succeed(done, undefined)), Effect.asVoid); + yield* Option.match(action.entry.scope, { + onNone: () => Effect.void, + onSome: (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore), + }).pipe(Effect.ensuring(finish)); + }); return DesktopBackendPool.of({ get: (id) => diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index 900afce9b860..348d3a7050f7 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -195,21 +195,6 @@ describe("DesktopLocalEnvironmentAuth", () => { return { auth, requestCount }; }); - it.effect("retries a backend that is still starting", () => - Effect.gen(function* () { - const { auth, requestCount } = yield* makeExchange( - 2, - () => new Response("", { status: 503 }), - ); - - const fiber = yield* auth.getBearerToken.pipe(Effect.forkChild); - yield* TestClock.adjust(Duration.seconds(1)); - - assert.strictEqual(yield* Fiber.join(fiber), "desktop-bearer-token"); - assert.strictEqual(yield* Ref.get(requestCount), 3); - }).pipe(Effect.provide(TestClock.layer())), - ); - it.effect("does not retry a rejected bootstrap credential", () => Effect.gen(function* () { const { auth, requestCount } = yield* makeExchange(1, () => diff --git a/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts b/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts deleted file mode 100644 index 679d19a53d0a..000000000000 --- a/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { beforeEach, vi } from "vite-plus/test"; - -const { networkInterfacesMock } = vi.hoisted(() => ({ - networkInterfacesMock: vi.fn(), -})); - -vi.mock("node:os", () => ({ - networkInterfaces: networkInterfacesMock, -})); - -import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; - -const layerTest = DesktopNetworkInterfaces.layer.pipe( - Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), -); - -describe("DesktopNetworkInterfaces", () => { - beforeEach(() => { - networkInterfacesMock.mockReset(); - }); - - it.effect("reads network interfaces through the service", () => { - const interfaces = { - en0: [ - { - address: "192.168.1.10", - family: "IPv4", - internal: false, - }, - ], - }; - networkInterfacesMock.mockReturnValueOnce(interfaces); - - return Effect.gen(function* () { - const service = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; - assert.strictEqual(yield* service.read, interfaces); - }).pipe(Effect.provide(layerTest)); - }); - - it.effect("preserves network interface read failures as structured defects", () => { - const cause = new Error("network interface probe failed"); - networkInterfacesMock.mockImplementationOnce(() => { - throw cause; - }); - - return Effect.gen(function* () { - const service = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; - const exit = yield* Effect.exit(service.read); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, DesktopNetworkInterfaces.DesktopNetworkInterfacesReadError); - assert.equal(error.platform, "linux"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message, "Failed to read desktop network interfaces on linux."); - } - }).pipe(Effect.provide(layerTest)); - }); -}); diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 5e3d25392ef8..f53cde3e451d 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -40,7 +40,7 @@ const tailnetNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { ], }; -function layerMockSpawner(statusJson = "{}", onSpawn?: () => void) { +function layerMockSpawner(statusJson: string | (() => string) = "{}", onSpawn?: () => void) { return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => @@ -53,7 +53,9 @@ function layerMockSpawner(statusJson = "{}", onSpawn?: () => void) { kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.make(encoder.encode(statusJson)), + stdout: Stream.make( + encoder.encode(typeof statusJson === "string" ? statusJson : statusJson()), + ), stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, @@ -64,13 +66,6 @@ function layerMockSpawner(statusJson = "{}", onSpawn?: () => void) { ); } -function layerDieOnSpawn() { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected tailscale spawn")), - ); -} - function layerEnvironmentFor(baseDir: string, env: Record = {}) { return DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", @@ -196,20 +191,6 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("returns a typed error when network access is explicitly unavailable", () => - withHarness( - emptyNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); - - const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); - assert.ok(error._tag === "DesktopServerExposureNoNetworkAddressError"); - assert.equal(error.port, 4173); - }), - ), - ); - it.effect("persists network-accessible mode and updates backend binding state", () => withHarness( lanNetworkInterfaces, @@ -257,22 +238,6 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("keeps a host bridge's LAN address ahead of later adapters", () => - withHarness( - { - vmbr0: [{ address: "192.168.1.30", family: "IPv4", internal: false }], - enx00e04c680001: [{ address: "192.168.42.10", family: "IPv4", internal: false }], - }, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); - - const change = yield* serverExposure.setMode("network-accessible"); - assert.equal(change.state.advertisedHost, "192.168.1.30"); - }), - ), - ); - it.effect("persists tailscale serve preferences atomically and reports no-op updates", () => withHarness( emptyNetworkInterfaces, @@ -304,71 +269,6 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("preserves persistence request context and the settings failure chain", () => { - const diskFailure = new Error("disk exploded"); - const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ - operation: "replace-settings-file", - path: "/tmp/desktop-settings.json", - cause: diskFailure, - }); - const layerSettings = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { - get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), - load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), - setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), - setServerExposureMode: () => Effect.fail(settingsFailure), - setTailscaleServe: () => Effect.fail(settingsFailure), - setUpdateChannel: () => Effect.die("unexpected update channel change"), - setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), - setWslDistro: () => Effect.die("unexpected WSL distro change"), - setWslOnly: () => Effect.die("unexpected WSL-only toggle"), - setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), - applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), - applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), - } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); - - return withHarness( - lanNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); - - const modeError = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); - assert.instanceOf( - modeError, - DesktopServerExposure.DesktopServerExposureModePersistenceError, - ); - assert.equal(modeError.mode, "network-accessible"); - assert.strictEqual(modeError.cause, settingsFailure); - assert.strictEqual(modeError.cause.cause, diskFailure); - assert.equal( - modeError.message, - "Failed to persist desktop server exposure mode network-accessible.", - ); - assert.notInclude(modeError.message, diskFailure.message); - - const tailscaleError = yield* serverExposure - .setTailscaleServeEnabled({ enabled: true, port: 8443 }) - .pipe(Effect.flip); - assert.instanceOf( - tailscaleError, - DesktopServerExposure.DesktopTailscaleServePersistenceError, - ); - assert.equal(tailscaleError.enabled, true); - assert.equal(tailscaleError.port, 8443); - assert.strictEqual(tailscaleError.cause, settingsFailure); - assert.strictEqual(tailscaleError.cause.cause, diskFailure); - assert.equal( - tailscaleError.message, - "Failed to persist desktop Tailscale Serve settings (enabled: true, port: 8443).", - ); - assert.notInclude(tailscaleError.message, diskFailure.message); - }), - {}, - undefined, - layerSettings, - ); - }); - it.effect("keeps a Tailscale Serve change made while a mode change is saving", () => Effect.gen(function* () { const modeWriteStarted = yield* Deferred.make(); @@ -460,22 +360,64 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("does not spawn the tailscale CLI while server exposure is local-only", () => - withHarness( - lanNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); - - const endpoints = yield* serverExposure.getAdvertisedEndpoints; - assert.deepEqual( - endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["http://127.0.0.1:4173/"], - ); - }), - {}, - layerDieOnSpawn(), - ), + it.effect( + "refreshes Tailscale status when addresses change and caches unchanged addresses", + () => { + const networkInterfaces: Record< + string, + readonly DesktopNetworkInterfaces.DesktopNetworkInterfaceInfo[] | undefined + > = { ...lanNetworkInterfaces }; + let statusJson = "{}"; + let statusReads = 0; + + return withHarness( + networkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.setMode("network-accessible"); + + const initial = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + initial.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/"], + ); + assert.equal(statusReads, 1); + + for (const [address, dnsName] of [ + ["100.90.1.2", "desktop.tail.ts.net"], + ["100.90.1.3", "desktop.other-tail.ts.net"], + ] as const) { + networkInterfaces.vpn0 = [{ address, family: "IPv4", internal: false }]; + statusJson = JSON.stringify({ + Self: { DNSName: `${dnsName}.`, TailscaleIPs: [address] }, + }); + const previousReads = statusReads; + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + [ + "http://127.0.0.1:4173/", + "http://192.168.1.20:4173/", + `http://${address}:4173/`, + `https://${dnsName}/`, + ], + ); + assert.equal(statusReads, previousReads + 1); + assert.deepEqual(yield* serverExposure.getAdvertisedEndpoints, endpoints); + assert.equal(statusReads, previousReads + 1); + } + }), + {}, + layerMockSpawner( + () => statusJson, + () => { + statusReads += 1; + }, + ), + ); + }, ); it.effect("preserves explicit Tailscale exposure overrides", () => @@ -501,104 +443,4 @@ describe("DesktopServerExposure", () => { }, ), ); - - it.effect("advertises loopback, LAN, and configured manual endpoints from runtime state", () => - withHarness( - lanNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 3773 }); - yield* serverExposure.setMode("network-accessible"); - - const endpoints = yield* serverExposure.getAdvertisedEndpoints; - assert.deepEqual(endpoints, [ - { - id: "desktop-loopback:3773", - label: "This machine", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "core", - isAddon: false, - }, - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - reachability: "loopback", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - description: "Loopback endpoint for this desktop app.", - }, - { - id: "desktop-lan:http://192.168.1.20:3773", - label: "Local network", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "core", - isAddon: false, - }, - httpBaseUrl: "http://192.168.1.20:3773/", - wsBaseUrl: "ws://192.168.1.20:3773/", - reachability: "lan", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - isDefault: true, - description: "Reachable from devices on the same network.", - }, - { - id: "manual:https://desktop.example.ts.net", - label: "Custom HTTPS", - provider: { - id: "manual", - label: "Manual", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "https://desktop.example.ts.net/", - wsBaseUrl: "wss://desktop.example.ts.net/", - reachability: "public", - compatibility: { - hostedHttpsApp: "compatible", - desktopApp: "compatible", - }, - source: "user", - status: "unknown", - description: "User-configured HTTPS endpoint for this desktop backend.", - }, - { - id: "manual:http://desktop.example.test:3773", - label: "Custom endpoint", - provider: { - id: "manual", - label: "Manual", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "http://desktop.example.test:3773/", - wsBaseUrl: "ws://desktop.example.test:3773/", - reachability: "public", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "user", - status: "unknown", - description: "User-configured endpoint for this desktop backend.", - }, - ]); - }), - { - T3CODE_DESKTOP_HTTPS_ENDPOINTS: - "https://desktop.example.ts.net,http://desktop.example.test:3773,not-a-url", - }, - ), - ); }); diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index b116622576b6..f9a69692a250 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -10,6 +10,7 @@ import { type DesktopServerExposureState, } from "@t3tools/contracts"; import { isTailscaleIpv4Address, readTailscaleStatus } from "@t3tools/tailscale"; +import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -416,6 +417,25 @@ function resolveRuntimeState(input: { }; } +const tailscaleIpv4AddressKey = ( + networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, +): string => + [ + ...new Set( + Object.values(networkInterfaces) + .flatMap((addresses) => addresses ?? []) + .filter( + (address) => + !address.internal && + address.family === "IPv4" && + isTailscaleIpv4Address(address.address), + ) + .map((address) => address.address), + ), + ] + .toSorted() + .join(","); + const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): boolean => previous.port !== next.port || previous.bindHost !== next.bindHost || @@ -434,16 +454,17 @@ export const make = Effect.gen(function* () { // with values it read before that change persisted. const changePermit = yield* Semaphore.make(1); - // Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App - // Store Tailscale CLI lives inside Tailscale's sandbox container, so each - // spawn re-triggers the "Other apps" TCC prompt. - const cachedReadTailscaleStatus = yield* Effect.cachedWithTTL( - readTailscaleStatus.pipe( - Effect.orElseSucceed(() => null), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - ), - TAILSCALE_STATUS_CACHE_TTL, - ); + // Key status by interface addresses so network changes bypass stale DNS and + // identity. Cache unchanged addresses to avoid repeated macOS TCC prompts. + const tailscaleStatusCache = yield* Cache.make({ + capacity: 1, + timeToLive: TAILSCALE_STATUS_CACHE_TTL, + lookup: (_addresses: string) => + readTailscaleStatus.pipe( + Effect.orElseSucceed(() => null), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ), + }); const readNetworkInterfaces = networkInterfaces.read; @@ -564,7 +585,10 @@ export const make = Effect.gen(function* () { serveEnabled: state.tailscaleServeEnabled, servePort: state.tailscaleServePort, networkInterfaces: currentNetworkInterfaces, - readStatus: cachedReadTailscaleStatus, + readStatus: Cache.get( + tailscaleStatusCache, + tailscaleIpv4AddressKey(currentNetworkInterfaces), + ), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index 1911654bdfa5..054655917cec 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -4,10 +4,7 @@ import * as Layer from "effect/Layer"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; -import { - parseTailscaleMagicDnsName, - resolveTailscaleAdvertisedEndpoints, -} from "./tailscaleEndpointProvider.ts"; +import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; const layerUnusedTailscaleExternalServices = Layer.mergeAll( Layer.succeed( @@ -40,19 +37,6 @@ describe("tailscale endpoint provider", () => { }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), ); - it.effect( - "does not advertise CGNAT interfaces when Tailscale reports no assigned addresses", - () => - Effect.gen(function* () { - const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: { pvpnksintrf1: protonAndTailscaleInterfaces.pvpnksintrf1 }, - statusJson: `{"BackendState":"NoState","Self":{"TailscaleIPs":null}}`, - }); - assert.deepEqual(endpoints, []); - }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), - ); - it.effect("keeps native Tailscale interfaces with missing, malformed, or stale status", () => Effect.gen(function* () { for (const interfaceName of ["tailscale0", "Tailscale"]) { @@ -95,164 +79,4 @@ describe("tailscale endpoint provider", () => { ); }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), ); - - it.effect("uses assigned addresses for renamed adapters without IPv6", () => - Effect.gen(function* () { - const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: { - vpn0: [{ address: "100.74.126.34", family: "IPv4", internal: false }], - }, - statusJson: `{"Self":{"TailscaleIPs":["100.74.126.34"]}}`, - }); - assert.deepEqual( - endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["http://100.74.126.34:3773/"], - ); - }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), - ); - - it.effect("does not advertise cached Tailscale addresses absent from local interfaces", () => - Effect.gen(function* () { - const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: { pvpnksintrf1: protonAndTailscaleInterfaces.pvpnksintrf1 }, - statusJson: `{"Self":{"TailscaleIPs":["100.74.126.35"]}}`, - }); - assert.deepEqual(endpoints, []); - }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), - ); - - it.effect("parses MagicDNS names from tailscale status", () => - Effect.gen(function* () { - const dnsName = yield* parseTailscaleMagicDnsName( - `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, - ); - assert.equal(dnsName, "desktop.tail.ts.net"); - assert.equal(yield* parseTailscaleMagicDnsName("{}"), null); - const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json")); - assert.isTrue(malformed._tag === "Failure"); - }), - ); - - it.effect("resolves Tailscale endpoints as add-on advertised endpoints", () => - Effect.gen(function* () { - const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: { - tailscale0: [ - { - address: "100.100.100.100", - family: "IPv4", - internal: false, - netmask: "255.192.0.0", - cidr: "100.100.100.100/10", - mac: "00:00:00:00:00:00", - }, - ], - }, - statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100"]}}`, - }); - assert.deepEqual(endpoints, [ - { - id: "tailscale-ip:http://100.100.100.100:3773", - label: "Tailscale IP", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "http://100.100.100.100:3773/", - wsBaseUrl: "ws://100.100.100.100:3773/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "available", - description: "Reachable from devices on the same Tailnet.", - }, - { - id: "tailscale-magicdns:https://desktop.tail.ts.net/", - label: "Tailscale HTTPS", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "https://desktop.tail.ts.net/", - wsBaseUrl: "wss://desktop.tail.ts.net/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "requires-configuration", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "unavailable", - description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", - }, - ]); - }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), - ); - - it.effect("uses one injected status reader for both IP and MagicDNS endpoints", () => - Effect.gen(function* () { - let readerCalls = 0; - const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: protonAndTailscaleInterfaces, - readStatus: Effect.sync(() => { - readerCalls += 1; - return { - magicDnsName: "desktop.tail.ts.net", - tailnetIpv4Addresses: ["100.74.126.34"], - }; - }), - }); - assert.equal(readerCalls, 1); - assert.deepEqual( - endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["http://100.74.126.34:3773/", "https://desktop.tail.ts.net/"], - ); - }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), - ); - - it.effect( - "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", - () => - Effect.gen(function* () { - const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: {}, - statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, - serveEnabled: true, - probe: () => Effect.succeed(true), - }); - assert.deepEqual(endpoints, [ - { - id: "tailscale-magicdns:https://desktop.tail.ts.net/", - label: "Tailscale HTTPS", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "https://desktop.tail.ts.net/", - wsBaseUrl: "wss://desktop.tail.ts.net/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "compatible", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "available", - description: "HTTPS endpoint served by Tailscale Serve.", - }, - ]); - }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), - ); }); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index ec3819ae98af..044625a32b44 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -15,8 +15,6 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import type { NetworkInterfaces } from "./DesktopNetworkInterfaces.ts"; -export { parseTailscaleMagicDnsName } from "@t3tools/tailscale"; - const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { id: "tailscale", label: "Tailscale", diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts deleted file mode 100644 index 84f37b6f5f53..000000000000 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import { beforeEach, vi } from "vite-plus/test"; - -const { - appendSwitchMock, - autoUpdaterOnMock, - autoUpdaterRemoveListenerMock, - exitMock, - getAppPathMock, - getSystemLocaleMock, - getVersionMock, - onMock, - quitMock, - relaunchMock, - removeListenerMock, - removeSwitchMock, - setAboutPanelOptionsMock, - setAppUserModelIdMock, - setAsDefaultProtocolClientMock, - setDesktopNameMock, - setDockIconMock, - setNameMock, - setPathMock, - whenReadyMock, -} = vi.hoisted(() => ({ - appendSwitchMock: vi.fn(), - autoUpdaterOnMock: vi.fn(), - autoUpdaterRemoveListenerMock: vi.fn(), - exitMock: vi.fn(), - getAppPathMock: vi.fn(() => "/app"), - getSystemLocaleMock: vi.fn(() => "en-GB"), - getVersionMock: vi.fn(() => "1.2.3"), - onMock: vi.fn(), - quitMock: vi.fn(), - relaunchMock: vi.fn(), - removeListenerMock: vi.fn(), - removeSwitchMock: vi.fn(), - setAboutPanelOptionsMock: vi.fn(), - setAppUserModelIdMock: vi.fn(), - setAsDefaultProtocolClientMock: vi.fn(() => true), - setDesktopNameMock: vi.fn(), - setDockIconMock: vi.fn(), - setNameMock: vi.fn(), - setPathMock: vi.fn(), - whenReadyMock: vi.fn(() => Promise.resolve()), -})); - -vi.mock("electron", () => ({ - autoUpdater: { - on: autoUpdaterOnMock, - removeListener: autoUpdaterRemoveListenerMock, - }, - app: { - commandLine: { - appendSwitch: appendSwitchMock, - removeSwitch: removeSwitchMock, - }, - dock: { - setIcon: setDockIconMock, - }, - getAppPath: getAppPathMock, - getSystemLocale: getSystemLocaleMock, - getVersion: getVersionMock, - isPackaged: true, - name: "T3 Code", - on: onMock, - quit: quitMock, - relaunch: relaunchMock, - removeListener: removeListenerMock, - runningUnderARM64Translation: false, - setAboutPanelOptions: setAboutPanelOptionsMock, - setAsDefaultProtocolClient: setAsDefaultProtocolClientMock, - setAppUserModelId: setAppUserModelIdMock, - setDesktopName: setDesktopNameMock, - setName: setNameMock, - setPath: setPathMock, - whenReady: whenReadyMock, - exit: exitMock, - }, -})); - -import * as ElectronApp from "./ElectronApp.ts"; - -describe("ElectronApp", () => { - beforeEach(() => { - appendSwitchMock.mockClear(); - autoUpdaterOnMock.mockClear(); - autoUpdaterRemoveListenerMock.mockClear(); - exitMock.mockClear(); - onMock.mockClear(); - quitMock.mockClear(); - relaunchMock.mockClear(); - removeListenerMock.mockClear(); - removeSwitchMock.mockClear(); - setPathMock.mockClear(); - }); - - it.effect("reads app metadata through the service", () => - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - const metadata = yield* electronApp.metadata; - - assert.deepEqual(metadata, { - appVersion: "1.2.3", - appPath: "/app", - isPackaged: true, - resourcesPath: process.resourcesPath, - runningUnderArm64Translation: false, - }); - }).pipe(Effect.provide(ElectronApp.layer)), - ); - - it.effect("reads the OS locale through the service", () => - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - - assert.strictEqual(yield* electronApp.systemLocale, "en-GB"); - }).pipe(Effect.provide(ElectronApp.layer)), - ); - - it.effect("normalizes POSIX-style locale identifiers that Intl rejects", () => - Effect.gen(function* () { - getSystemLocaleMock.mockImplementationOnce(() => "en_GB"); - const electronApp = yield* ElectronApp.ElectronApp; - - assert.strictEqual(yield* electronApp.systemLocale, "en-GB"); - }).pipe(Effect.provide(ElectronApp.layer)), - ); - - it.effect("reports which app metadata property failed", () => - Effect.gen(function* () { - const cause = new Error("version unavailable"); - getVersionMock.mockImplementationOnce(() => { - throw cause; - }); - - const electronApp = yield* ElectronApp.ElectronApp; - const error = yield* electronApp.metadata.pipe(Effect.flip); - - assert.instanceOf(error, ElectronApp.ElectronAppMetadataReadError); - assert.strictEqual(error.property, "app-version"); - assert.strictEqual(error.cause, cause); - assert.strictEqual( - error.message, - 'Failed to read Electron app metadata property "app-version".', - ); - }).pipe(Effect.provide(ElectronApp.layer)), - ); - - it.effect("preserves Electron readiness failures", () => - Effect.gen(function* () { - const cause = new Error("ready failed"); - whenReadyMock.mockRejectedValueOnce(cause); - - const electronApp = yield* ElectronApp.ElectronApp; - const error = yield* electronApp.whenReady.pipe(Effect.flip); - - assert.instanceOf(error, ElectronApp.ElectronAppWhenReadyError); - assert.strictEqual(error.isPackaged, true); - assert.strictEqual(error.cause, cause); - assert.strictEqual( - error.message, - "Failed to wait for the Electron app to become ready (packaged: true).", - ); - }).pipe(Effect.provide(ElectronApp.layer)), - ); - - it.effect("scopes app event listeners", () => - Effect.gen(function* () { - const listener = vi.fn(); - - yield* Effect.scoped( - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - yield* electronApp.on("activate", listener); - }), - ); - - assert.deepEqual(onMock.mock.calls, [["activate", listener]]); - assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]); - }).pipe(Effect.provide(ElectronApp.layer)), - ); - - it.effect("scopes native updater quit listeners", () => - Effect.gen(function* () { - const listener = vi.fn(); - - yield* Effect.scoped( - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - yield* electronApp.onBeforeQuitForUpdate(listener); - }), - ); - - assert.deepEqual(autoUpdaterOnMock.mock.calls, [["before-quit-for-update", listener]]); - assert.deepEqual(autoUpdaterRemoveListenerMock.mock.calls, [ - ["before-quit-for-update", listener], - ]); - }).pipe(Effect.provide(ElectronApp.layer)), - ); - - it.effect("removes command-line switches through the service", () => - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - yield* electronApp.removeCommandLineSwitch("password-store"); - - assert.deepEqual(removeSwitchMock.mock.calls, [["password-store"]]); - }).pipe(Effect.provide(ElectronApp.layer)), - ); -}); diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts deleted file mode 100644 index 2ed5a1f2f913..000000000000 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import type { BrowserWindow } from "electron"; -import { beforeEach, vi } from "vite-plus/test"; - -import * as ElectronDialog from "./ElectronDialog.ts"; - -const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock } = vi.hoisted(() => ({ - showMessageBoxMock: vi.fn(), - showOpenDialogMock: vi.fn(), - showErrorBoxMock: vi.fn(), -})); - -vi.mock("electron", () => ({ - dialog: { - showMessageBox: showMessageBoxMock, - showOpenDialog: showOpenDialogMock, - showErrorBox: showErrorBoxMock, - }, -})); - -describe("ElectronDialog", () => { - beforeEach(() => { - showMessageBoxMock.mockReset(); - showOpenDialogMock.mockReset(); - showErrorBoxMock.mockReset(); - }); - - it.effect("preserves folder picker request context and cause", () => - Effect.gen(function* () { - const cause = new Error("folder picker failed"); - const owner = { id: 7 } as BrowserWindow; - showOpenDialogMock.mockRejectedValue(cause); - const dialog = yield* ElectronDialog.ElectronDialog; - - const error = yield* Effect.flip( - dialog.pickFolder({ - owner: Option.some(owner), - defaultPath: Option.some("/workspace"), - }), - ); - - assert.instanceOf(error, ElectronDialog.ElectronDialogPickFolderError); - assert.strictEqual(error.ownerWindowId, 7); - assert.strictEqual(error.defaultPath, "/workspace"); - assert.strictEqual(error.cause, cause); - assert.include(error.message, "window 7"); - assert.include(error.message, "/workspace"); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), - ); - - it.effect("opens a single-file picker when multiple selections are disabled", () => - Effect.gen(function* () { - showOpenDialogMock.mockResolvedValue({ - canceled: false, - filePaths: ["/pictures/icon.png"], - }); - const dialog = yield* ElectronDialog.ElectronDialog; - - const paths = yield* dialog.pickFiles({ - owner: Option.none(), - defaultPath: Option.some("/project"), - filters: [{ name: "Images", extensions: ["png"] }], - multiple: false, - }); - - assert.deepEqual(paths, ["/pictures/icon.png"]); - assert.deepEqual(showOpenDialogMock.mock.calls, [ - [ - { - defaultPath: "/project", - filters: [{ name: "Images", extensions: ["png"] }], - properties: ["openFile"], - }, - ], - ]); - }).pipe(Effect.provide(ElectronDialog.layer)), - ); - - it.effect("preserves message box request context and cause", () => - Effect.gen(function* () { - const cause = new Error("message box failed"); - showMessageBoxMock.mockRejectedValue(cause); - const dialog = yield* ElectronDialog.ElectronDialog; - - const error = yield* Effect.flip( - dialog.showMessageBox({ - type: "warning", - title: "Unsaved changes", - message: "Discard changes?", - detail: "This cannot be undone.", - buttons: ["Cancel", "Discard"], - }), - ); - - assert.instanceOf(error, ElectronDialog.ElectronDialogShowMessageBoxError); - assert.strictEqual(error.type, "warning"); - assert.strictEqual(error.titleLength, "Unsaved changes".length); - assert.strictEqual(error.messageLength, "Discard changes?".length); - assert.strictEqual(error.detailLength, "This cannot be undone.".length); - assert.strictEqual(error.buttonCount, 2); - assert.notProperty(error, "title"); - assert.notProperty(error, "dialogMessage"); - assert.notProperty(error, "dialogDetail"); - assert.notProperty(error, "buttons"); - assert.strictEqual(error.cause, cause); - assert.include(error.message, "warning"); - assert.notInclude(error.message, "Unsaved changes"); - assert.notInclude(error.message, "Discard changes?"); - assert.notInclude(error.message, "This cannot be undone."); - assert.notInclude(error.message, "Cancel"); - assert.notInclude(error.message, "Discard"); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), - ); - - it.effect("preserves error box request context and cause in the defect", () => - Effect.gen(function* () { - const cause = new Error("error box failed"); - showErrorBoxMock.mockImplementation(() => { - throw cause; - }); - const dialog = yield* ElectronDialog.ElectronDialog; - - const exit = yield* Effect.exit(dialog.showErrorBox("Startup failed", "Could not start.")); - - assert.isTrue(exit._tag === "Failure"); - if (exit._tag === "Success") return; - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronDialog.ElectronDialogShowErrorBoxError); - assert.strictEqual(error.titleLength, "Startup failed".length); - assert.strictEqual(error.contentLength, "Could not start.".length); - assert.notProperty(error, "title"); - assert.notProperty(error, "content"); - assert.strictEqual(error.cause, cause); - assert.notInclude(error.message, "Startup failed"); - assert.notInclude(error.message, "Could not start."); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), - ); -}); diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts deleted file mode 100644 index 320988051e5f..000000000000 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import type * as Electron from "electron"; -import { beforeEach, vi } from "vite-plus/test"; - -const { buildFromTemplateMock, createFromNamedImageMock, setApplicationMenuMock } = vi.hoisted( - () => ({ - buildFromTemplateMock: vi.fn(), - createFromNamedImageMock: vi.fn(), - setApplicationMenuMock: vi.fn(), - }), -); - -vi.mock("electron", () => ({ - Menu: { - buildFromTemplate: buildFromTemplateMock, - setApplicationMenu: setApplicationMenuMock, - }, - nativeImage: { - createFromNamedImage: createFromNamedImageMock, - }, -})); - -import * as ElectronMenu from "./ElectronMenu.ts"; - -const layerTest = ElectronMenu.layer.pipe( - Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), -); - -const makeWindow = (zoomFactor = 1): Electron.BrowserWindow => - ({ - id: 7, - webContents: { getZoomFactor: () => zoomFactor }, - }) as unknown as Electron.BrowserWindow; - -describe("ElectronMenu", () => { - beforeEach(() => { - buildFromTemplateMock.mockReset(); - createFromNamedImageMock.mockReset(); - setApplicationMenuMock.mockReset(); - }); - - it.effect("returns none without building a menu when there are no valid items", () => - Effect.gen(function* () { - const electronMenu = yield* ElectronMenu.ElectronMenu; - const selectedItemId = yield* electronMenu.showContextMenu({ - window: {} as Electron.BrowserWindow, - items: [], - position: Option.none(), - }); - - assert.isTrue(Option.isNone(selectedItemId)); - assert.equal(buildFromTemplateMock.mock.calls.length, 0); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("resolves with the clicked leaf item id", () => - Effect.gen(function* () { - buildFromTemplateMock.mockImplementation( - (template: Electron.MenuItemConstructorOptions[]) => ({ - popup: () => { - const firstItem = template[0]; - assert.isDefined(firstItem); - const click = firstItem.click; - if (!click) { - throw new Error("Expected menu item to have a click handler."); - } - click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); - }, - }), - ); - - const electronMenu = yield* ElectronMenu.ElectronMenu; - const selectedItemId = yield* electronMenu.showContextMenu({ - window: makeWindow(), - items: [{ id: "copy", label: "Copy" }], - position: Option.none(), - }); - - assert.equal(Option.getOrNull(selectedItemId), "copy"); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("resolves with none when the menu closes without a click", () => - Effect.gen(function* () { - let popupOptions: Electron.PopupOptions | undefined; - buildFromTemplateMock.mockImplementation(() => ({ - popup: (options: Electron.PopupOptions) => { - popupOptions = options; - options.callback?.(); - }, - })); - - const electronMenu = yield* ElectronMenu.ElectronMenu; - const selectedItemId = yield* electronMenu.showContextMenu({ - window: makeWindow(2), - items: [ - { id: "copy", label: "Copy" }, - { id: "delete", label: "Delete", destructive: true, separatorBefore: true }, - ], - position: Option.some({ x: 10.8, y: 20.2 }), - }); - - assert.isTrue(Option.isNone(selectedItemId)); - assert.equal(popupOptions?.x, 21); - assert.equal(popupOptions?.y, 40); - assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], { - label: "Copy", - enabled: true, - click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, - }); - assert.deepEqual( - buildFromTemplateMock.mock.calls[0]?.[0].map( - (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label, - ), - ["Copy", "separator", "Delete"], - ); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("keeps a preceding non-destructive action in the destructive section", () => - Effect.gen(function* () { - buildFromTemplateMock.mockImplementation(() => ({ - popup: (options: Electron.PopupOptions) => options.callback?.(), - })); - - const electronMenu = yield* ElectronMenu.ElectronMenu; - yield* electronMenu.showContextMenu({ - window: makeWindow(), - items: [ - { id: "copy", label: "Copy" }, - { id: "archive", label: "Archive", separatorBefore: true }, - { id: "delete", label: "Delete", destructive: true }, - ], - position: Option.none(), - }); - - assert.deepEqual( - buildFromTemplateMock.mock.calls[0]?.[0].map( - (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label, - ), - ["Copy", "separator", "Archive", "Delete"], - ); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("defers popupTemplate side effects until the returned Effect runs", () => - Effect.gen(function* () { - const popupMock = vi.fn(); - buildFromTemplateMock.mockImplementation(() => ({ popup: popupMock })); - - const electronMenu = yield* ElectronMenu.ElectronMenu; - const frame = { routingId: 7 } as Electron.WebFrameMain; - const popup = electronMenu.popupTemplate({ - window: {} as Electron.BrowserWindow, - template: [{ label: "Copy" }], - frame, - }); - - assert.equal(buildFromTemplateMock.mock.calls.length, 0); - assert.equal(popupMock.mock.calls.length, 0); - - yield* popup; - - assert.equal(buildFromTemplateMock.mock.calls.length, 1); - assert.equal(popupMock.mock.calls.length, 1); - assert.strictEqual(popupMock.mock.calls[0]?.[0].frame, frame); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("preserves application-menu failures as structured defects", () => - Effect.gen(function* () { - const cause = new Error("application menu build failed"); - buildFromTemplateMock.mockImplementationOnce(() => { - throw cause; - }); - - const electronMenu = yield* ElectronMenu.ElectronMenu; - const exit = yield* Effect.exit( - electronMenu.setApplicationMenu([{ label: "File" }, { label: "Edit" }]), - ); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError); - assert.equal(error.operation, "set-application-menu"); - assert.equal(error.platform, "linux"); - assert.isNull(error.windowId); - assert.equal(error.itemCount, 2); - assert.strictEqual(error.cause, cause); - assert.notInclude(error.message, cause.message); - } - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("preserves popup-template failures with window context", () => - Effect.gen(function* () { - const cause = new Error("popup failed"); - buildFromTemplateMock.mockReturnValueOnce({ - popup: () => { - throw cause; - }, - }); - - const electronMenu = yield* ElectronMenu.ElectronMenu; - const exit = yield* Effect.exit( - electronMenu.popupTemplate({ - window: { id: 41 } as Electron.BrowserWindow, - template: [{ label: "Copy" }], - }), - ); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError); - assert.equal(error.operation, "popup-template"); - assert.equal(error.windowId, 41); - assert.equal(error.itemCount, 1); - assert.strictEqual(error.cause, cause); - } - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("preserves context-menu failures with normalized item context", () => - Effect.gen(function* () { - const cause = new Error("context menu build failed"); - buildFromTemplateMock.mockImplementationOnce(() => { - throw cause; - }); - - const electronMenu = yield* ElectronMenu.ElectronMenu; - const exit = yield* Effect.exit( - electronMenu.showContextMenu({ - window: { id: 42 } as Electron.BrowserWindow, - items: [{ id: "copy", label: "Copy" }], - position: Option.none(), - }), - ); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError); - assert.equal(error.operation, "show-context-menu"); - assert.equal(error.windowId, 42); - assert.equal(error.itemCount, 1); - assert.strictEqual(error.cause, cause); - } - }).pipe(Effect.provide(layerTest)), - ); -}); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 4c9da1dd9259..45d1771f26f0 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -1,11 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as TestClock from "effect/testing/TestClock"; import { beforeEach, vi } from "vite-plus/test"; const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({ @@ -156,173 +153,4 @@ describe("ElectronProtocol", () => { assert.equal(netFetchMock.mock.calls.length, 0); }).pipe(Effect.provide(layerProtocol)), ); - - it.effect("retries transient renderer target failures", () => - Effect.gen(function* () { - let handler: ((request: Request) => Promise) | undefined; - handleMock.mockImplementation((_scheme, nextHandler) => { - handler = nextHandler; - }); - netFetchMock - .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733")) - .mockResolvedValueOnce(new Response("ready")); - - const response = yield* Effect.scoped( - Effect.gen(function* () { - const protocol = yield* ElectronProtocol.ElectronProtocol; - yield* protocol.registerDesktopProtocol({ - scheme: "t3code-dev", - targetOrigin: new URL("http://127.0.0.1:5733/"), - clerkFrontendApiHostname: undefined, - }); - const fiber = yield* Effect.forkChild( - Effect.promise(() => handler!(new Request("t3code-dev://app/"))), - ); - yield* TestClock.adjust("50 millis"); - return yield* Fiber.join(fiber); - }), - ); - - assert.equal(yield* Effect.promise(() => response.text()), "ready"); - assert.equal(netFetchMock.mock.calls.length, 2); - }).pipe(Effect.provide(layerProtocol)), - ); - - it.effect("rejects with the last renderer target failure after 50ms and 150ms retries", () => - Effect.gen(function* () { - let handler: ((request: Request) => Promise) | undefined; - handleMock.mockImplementation((_scheme, nextHandler) => { - handler = nextHandler; - }); - const lastFailure = new Error("connect ECONNREFUSED 127.0.0.1:5733 (3)"); - netFetchMock - .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733 (1)")) - .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733 (2)")) - .mockRejectedValueOnce(lastFailure); - - const rejection = yield* Effect.scoped( - Effect.gen(function* () { - const protocol = yield* ElectronProtocol.ElectronProtocol; - yield* protocol.registerDesktopProtocol({ - scheme: "t3code-dev", - targetOrigin: new URL("http://127.0.0.1:5733/"), - clerkFrontendApiHostname: undefined, - }); - const fiber = yield* Effect.forkChild( - Effect.promise(() => - handler!(new Request("t3code-dev://app/")).then( - () => null, - (error: unknown) => error, - ), - ), - ); - yield* TestClock.adjust("49 millis"); - assert.equal(netFetchMock.mock.calls.length, 1); - yield* TestClock.adjust("1 millis"); - assert.equal(netFetchMock.mock.calls.length, 2); - yield* TestClock.adjust("149 millis"); - assert.equal(netFetchMock.mock.calls.length, 2); - yield* TestClock.adjust("1 millis"); - return yield* Fiber.join(fiber); - }), - ); - - assert.strictEqual(rejection, lastFailure); - assert.equal(netFetchMock.mock.calls.length, 3); - }).pipe(Effect.provide(layerProtocol)), - ); - - it.effect("preserves protocol registration failures", () => - Effect.gen(function* () { - const cause = new Error("protocol registration failed"); - handleMock.mockImplementationOnce(() => { - throw cause; - }); - - const protocol = yield* ElectronProtocol.ElectronProtocol; - const error = yield* Effect.scoped( - protocol.registerDesktopProtocol({ - scheme: "t3code-dev", - targetOrigin: new URL("http://127.0.0.1:3773/"), - clerkFrontendApiHostname: undefined, - }), - ).pipe(Effect.flip); - - assert.instanceOf(error, ElectronProtocol.ElectronProtocolRegistrationError); - assert.equal(error.scheme, "t3code-dev"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".'); - }).pipe(Effect.provide(layerProtocol)), - ); - - it.effect("preserves protocol unregistration failures", () => - Effect.gen(function* () { - const cause = new Error("protocol unregistration failed"); - unhandleMock.mockImplementationOnce(() => { - throw cause; - }); - - const protocol = yield* ElectronProtocol.ElectronProtocol; - const exit = yield* Effect.exit( - Effect.scoped( - protocol.registerDesktopProtocol({ - scheme: "t3code", - targetOrigin: new URL("http://127.0.0.1:3773/"), - clerkFrontendApiHostname: undefined, - }), - ), - ); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronProtocol.ElectronProtocolUnregistrationError); - assert.equal(error.scheme, "t3code"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".'); - } - }).pipe(Effect.provide(layerProtocol)), - ); - - it("keeps executable sources host-restricted while allowing runtime network resources", () => { - const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ - scheme: "t3code", - targetOrigin: new URL("http://127.0.0.1:3773/"), - clerkFrontendApiHostname: "clerk.t3.codes", - }); - const directives = Object.fromEntries( - policy.split("; ").map((directive) => { - const [name, ...sources] = directive.split(" "); - return [name, sources]; - }), - ); - - assert.deepEqual(directives["script-src"], [ - "'self'", - "'unsafe-inline'", - "'wasm-unsafe-eval'", - "https://clerk.t3.codes", - "https://challenges.cloudflare.com", - ]); - assert.deepEqual(directives["connect-src"], [ - "'self'", - "blob:", - "http:", - "https:", - "ws:", - "wss:", - ]); - assert.deepEqual(directives["img-src"], [ - "'self'", - "t3code:", - "blob:", - "data:", - "http:", - "https:", - ]); - assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:", "http:", "https:"]); - assert.deepEqual(directives["frame-src"], ["'self'", "blob:", "http:", "https:"]); - assert.deepEqual(directives["object-src"], ["'self'", "blob:", "http:", "https:"]); - assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); - }); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 9abe17d77512..d6f476205f4c 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -66,7 +66,7 @@ export class ElectronProtocol extends Context.Service< } >()("@t3tools/desktop/electron/ElectronProtocol") {} -export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInput): string { +function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInput): string { const clerkOrigin = input.clerkFrontendApiHostname ? `https://${input.clerkFrontendApiHostname}` : undefined; diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index 4c9d44a7de85..e56ebf21ea66 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -96,8 +96,8 @@ export const make = Effect.gen(function* () { try: () => Electron.safeStorage.decryptString(Buffer.from(value)), catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), }), - decryptLegacyString: (value, accept) => - Effect.gen(function* () { + decryptLegacyString: Effect.fn("desktop.electron.safeStorage.decryptLegacyString")( + function* (value, accept) { const backend = yield* selectedStorageBackend; if (Option.isNone(backend) || backend.value !== "gnome_libsecret") { return Option.none(); @@ -110,7 +110,8 @@ export const make = Effect.gen(function* () { }, catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), }).pipe(Effect.map(Option.fromNullishOr)); - }), + }, + ), selectedStorageBackend, }); }); diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 580a8b95684d..0e72a46146e0 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -24,84 +24,18 @@ describe("ElectronShell", () => { writeTextMock.mockReset(); }); - it.effect("opens safe external URLs", () => - Effect.gen(function* () { - openExternalMock.mockResolvedValue(undefined); - - const electronShell = yield* ElectronShell.ElectronShell; - const result = yield* electronShell.openExternal("https://example.com/path"); - - assert.equal(result, true); - assert.deepEqual(openExternalMock.mock.calls, [["https://example.com/path"]]); - }).pipe(Effect.provide(ElectronShell.layer)), - ); - - it.effect("copies text to the system clipboard", () => - Effect.gen(function* () { - writeTextMock.mockResolvedValue(undefined); - - const electronShell = yield* ElectronShell.ElectronShell; - yield* electronShell.copyText("https://example.com/path"); - - assert.deepEqual(writeTextMock.mock.calls, [["https://example.com/path"]]); - }).pipe(Effect.provide(ElectronShell.layer)), - ); - - it.effect("does not fail when the clipboard write rejects", () => - Effect.gen(function* () { - writeTextMock.mockRejectedValue(new Error("write failed")); - - const electronShell = yield* ElectronShell.ElectronShell; - yield* electronShell.copyText("https://example.com/path"); - - assert.deepEqual(writeTextMock.mock.calls, [["https://example.com/path"]]); - }).pipe(Effect.provide(ElectronShell.layer)), - ); - - it.effect("opens the Full Disk Access settings anchor", () => - Effect.gen(function* () { - openExternalMock.mockResolvedValue(undefined); - - const electronShell = yield* ElectronShell.ElectronShell; - const result = yield* electronShell.openSystemSettings("full-disk-access"); - - assert.equal(result, true); - assert.deepEqual(openExternalMock.mock.calls, [ - ["x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles"], - ]); - }).pipe(Effect.provide(ElectronShell.layer)), - ); - it.effect("opens remote SSH editor URLs", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); const electronShell = yield* ElectronShell.ElectronShell; const result = yield* electronShell.openExternal( - "vscode://vscode-remote/ssh-remote+example.com/home/user/project", + "vscode://vscode-remote/ssh-remote+user%40example.com/home/user/project", ); assert.equal(result, true); assert.deepEqual(openExternalMock.mock.calls, [ - ["vscode://vscode-remote/ssh-remote+example.com/home/user/project"], - ]); - }).pipe(Effect.provide(ElectronShell.layer)), - ); - - it.effect("opens Zed's ssh deep link", () => - Effect.gen(function* () { - openExternalMock.mockResolvedValue(undefined); - - const electronShell = yield* ElectronShell.ElectronShell; - const results = yield* Effect.all([ - electronShell.openExternal("zed://ssh/example.com/home/user/project"), - electronShell.openExternal("zed://ssh/example.com/"), - ]); - - assert.deepEqual(results, [true, true]); - assert.deepEqual(openExternalMock.mock.calls, [ - ["zed://ssh/example.com/home/user/project"], - ["zed://ssh/example.com/"], + ["vscode://vscode-remote/ssh-remote+user%40example.com/home/user/project"], ]); }).pipe(Effect.provide(ElectronShell.layer)), ); @@ -111,7 +45,7 @@ describe("ElectronShell", () => { openExternalMock.mockResolvedValue(undefined); const url = - "jetbrains://gateway/ssh/environment?h=example.com&launchIde=true&ideHint=IU&projectHint=%2Fhome%2Fuser%2Fproject"; + "jetbrains://gateway/ssh/environment?h=example.com&launchIde=true&ideHint=IU&projectHint=%2Fhome%2Fuser%2Fproject&u=user"; const electronShell = yield* ElectronShell.ElectronShell; const result = yield* electronShell.openExternal(url); @@ -150,7 +84,7 @@ describe("ElectronShell", () => { electronShell.openExternal( "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project", ), - electronShell.openExternal("zed://ssh/user@example.com/home/user/project"), + electronShell.openExternal("zed://ssh/user:secret@example.com/home/user/project"), electronShell.openExternal("jetbrains://user@gateway/ssh/environment?h=example.com"), ]); @@ -168,29 +102,4 @@ describe("ElectronShell", () => { assert.equal(openExternalMock.mock.calls.length, 0); }).pipe(Effect.provide(ElectronShell.layer)), ); - - it.effect("does not open non-remote editor URLs", () => - Effect.gen(function* () { - openExternalMock.mockResolvedValue(undefined); - - const electronShell = yield* ElectronShell.ElectronShell; - const result = yield* electronShell.openExternal( - "vscode://ms-python.python/some-command?argument=attacker", - ); - - assert.equal(result, false); - assert.equal(openExternalMock.mock.calls.length, 0); - }).pipe(Effect.provide(ElectronShell.layer)), - ); - - it.effect("returns false when Electron rejects openExternal", () => - Effect.gen(function* () { - openExternalMock.mockRejectedValue(new Error("open failed")); - - const electronShell = yield* ElectronShell.ElectronShell; - const result = yield* electronShell.openExternal("https://example.com/path"); - - assert.equal(result, false); - }).pipe(Effect.provide(ElectronShell.layer)), - ); }); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index d6832c79eb80..0216d7b2c7dd 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -22,8 +22,8 @@ const REMOTE_EDITOR_PROTOCOLS = new Set( }), ); -// Zed's host sits in the first path segment, so it needs its own userinfo ban. -const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.*$/; +// Zed embeds [user@]host in the path; reject passwords and ports there. +const ZED_SSH_PATHNAME = /^\/(?:[^/@:]+@)?[^/@:]+\/.*$/; const isRemoteEditorUrl = (url: URL) => REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts deleted file mode 100644 index b4028930af66..000000000000 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import { beforeEach, vi } from "vite-plus/test"; - -const { onMock, removeListenerMock, themeState } = vi.hoisted(() => ({ - onMock: vi.fn(), - removeListenerMock: vi.fn(), - themeState: { - shouldUseDarkColors: true, - themeSource: "system", - setSourceError: null as unknown, - }, -})); - -vi.mock("electron", () => ({ - nativeTheme: { - get shouldUseDarkColors() { - return themeState.shouldUseDarkColors; - }, - set themeSource(value: string) { - if (themeState.setSourceError !== null) { - throw themeState.setSourceError; - } - themeState.themeSource = value; - }, - on: onMock, - removeListener: removeListenerMock, - }, -})); - -import * as ElectronTheme from "./ElectronTheme.ts"; - -describe("ElectronTheme", () => { - beforeEach(() => { - onMock.mockClear(); - removeListenerMock.mockClear(); - themeState.shouldUseDarkColors = true; - themeState.themeSource = "system"; - themeState.setSourceError = null; - }); - - it.effect("scopes native theme update listeners", () => - Effect.gen(function* () { - const listener = vi.fn(); - - yield* Effect.scoped( - Effect.gen(function* () { - const electronTheme = yield* ElectronTheme.ElectronTheme; - yield* electronTheme.onUpdated(listener); - }), - ); - - assert.deepEqual(onMock.mock.calls, [["updated", listener]]); - assert.deepEqual(removeListenerMock.mock.calls, [["updated", listener]]); - }).pipe(Effect.provide(ElectronTheme.layer)), - ); - - it.effect("preserves the requested source and cause when setting the theme fails", () => - Effect.gen(function* () { - const cause = new Error("theme source failed"); - themeState.setSourceError = cause; - const electronTheme = yield* ElectronTheme.ElectronTheme; - - const error = yield* Effect.flip(electronTheme.setSource("dark")); - - assert.instanceOf(error, ElectronTheme.ElectronThemeSetSourceError); - assert.strictEqual(error.source, "dark"); - assert.strictEqual(error.cause, cause); - assert.include(error.message, "dark"); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronTheme.layer)), - ); -}); diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts deleted file mode 100644 index 1e005d26fdf5..000000000000 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import { beforeEach, vi } from "vite-plus/test"; - -const { autoUpdaterMock } = vi.hoisted(() => ({ - autoUpdaterMock: { - allowDowngrade: false, - allowPrerelease: false, - autoDownload: true, - autoInstallOnAppQuit: true, - channel: "latest", - disableDifferentialDownload: false, - fullChangelog: false, - checkForUpdates: vi.fn(() => Promise.resolve(null)), - downloadUpdate: vi.fn(() => Promise.resolve([])), - on: vi.fn(), - quitAndInstall: vi.fn(), - removeListener: vi.fn(), - setFeedURL: vi.fn(), - }, -})); - -vi.mock("electron-updater", () => ({ - autoUpdater: autoUpdaterMock, -})); - -import * as ElectronUpdater from "./ElectronUpdater.ts"; - -describe("ElectronUpdater", () => { - beforeEach(() => { - autoUpdaterMock.allowDowngrade = false; - autoUpdaterMock.allowPrerelease = false; - autoUpdaterMock.autoDownload = true; - autoUpdaterMock.autoInstallOnAppQuit = true; - autoUpdaterMock.channel = "latest"; - autoUpdaterMock.disableDifferentialDownload = false; - autoUpdaterMock.fullChangelog = false; - autoUpdaterMock.checkForUpdates.mockClear(); - autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null)); - autoUpdaterMock.downloadUpdate.mockClear(); - autoUpdaterMock.downloadUpdate.mockImplementation(() => Promise.resolve([])); - autoUpdaterMock.on.mockClear(); - autoUpdaterMock.quitAndInstall.mockClear(); - autoUpdaterMock.removeListener.mockClear(); - autoUpdaterMock.setFeedURL.mockClear(); - }); - - it.effect("scopes updater event listeners", () => - Effect.gen(function* () { - const listener = vi.fn(); - - yield* Effect.scoped( - Effect.gen(function* () { - const updater = yield* ElectronUpdater.ElectronUpdater; - yield* updater.on("update-available", listener); - }), - ); - - assert.deepEqual(autoUpdaterMock.on.mock.calls, [["update-available", listener]]); - assert.deepEqual(autoUpdaterMock.removeListener.mock.calls, [["update-available", listener]]); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("wraps rejected update checks in the method-specific typed error", () => - Effect.gen(function* () { - const cause = new Error("network unavailable"); - autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => Promise.reject(cause)); - const updater = yield* ElectronUpdater.ElectronUpdater; - autoUpdaterMock.channel = "beta"; - - const error = yield* updater.checkForUpdates.pipe(Effect.flip); - - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); - assert.equal(error.channel, "beta"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message, "Electron updater failed to check for updates on channel beta."); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("preserves the execution-time channel on download failures", () => - Effect.gen(function* () { - const cause = new Error("download unavailable"); - autoUpdaterMock.downloadUpdate.mockImplementationOnce(() => Promise.reject(cause)); - const updater = yield* ElectronUpdater.ElectronUpdater; - autoUpdaterMock.channel = "nightly"; - - const error = yield* updater.downloadUpdate.pipe(Effect.flip); - - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); - assert.equal(error.channel, "nightly"); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - "Electron updater failed to download the update on channel nightly.", - ); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("sets full changelog mode", () => - Effect.gen(function* () { - const updater = yield* ElectronUpdater.ElectronUpdater; - - yield* updater.setFullChangelog(true); - assert.equal(autoUpdaterMock.fullChangelog, true); - - yield* updater.setFullChangelog(false); - assert.equal(autoUpdaterMock.fullChangelog, false); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("preserves quit-and-install flags and the execution-time channel", () => - Effect.gen(function* () { - const cause = new Error("quit and install failed"); - autoUpdaterMock.quitAndInstall.mockImplementationOnce(() => { - throw cause; - }); - const updater = yield* ElectronUpdater.ElectronUpdater; - autoUpdaterMock.channel = "alpha"; - - const error = yield* updater - .quitAndInstall({ isSilent: true, isForceRunAfter: false }) - .pipe(Effect.flip); - - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterQuitAndInstallError); - assert.equal(error.channel, "alpha"); - assert.equal(error.isSilent, true); - assert.equal(error.isForceRunAfter, false); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - "Electron updater failed to quit and install the update on channel alpha (silent: true, force run after: false).", - ); - assert.notInclude(error.message, cause.message); - assert.deepEqual(autoUpdaterMock.quitAndInstall.mock.calls, [[true, false]]); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); -}); diff --git a/apps/desktop/src/electron/ElectronWindow.module.test.ts b/apps/desktop/src/electron/ElectronWindow.module.test.ts deleted file mode 100644 index f272f84b5d82..000000000000 --- a/apps/desktop/src/electron/ElectronWindow.module.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { expect, it, vi } from "vite-plus/test"; - -vi.mock("@crowecawcaw/xa11y", () => { - throw new Error("Cannot find native binding"); -}); - -it("loads without an accessibility native binding", async () => { - await expect(import("./ElectronWindow.ts")).resolves.toBeDefined(); -}); diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index ef94312ed6f0..4f17ca7e3301 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -62,13 +62,6 @@ const layerTestFor = (platform: NodeJS.Platform) => const layerTest = layerTestFor("linux"); -function makeBrowserWindow(input: { readonly id: number; readonly destroyed: boolean }) { - return { - id: input.id, - isDestroyed: vi.fn(() => input.destroyed), - } as unknown as Electron.BrowserWindow; -} - function makeWindowsRevealWindow() { return { id: 41, @@ -109,341 +102,6 @@ describe("ElectronWindow", () => { windowsForegroundCloseMock.mockReset(); }); - it.effect("preserves schema-safe creation context and the Electron cause", () => - Effect.gen(function* () { - const cause = new Error("native BrowserWindow construction failed"); - browserWindowMock.mockImplementationOnce(function BrowserWindowFailure() { - throw cause; - }); - const options = { - title: "T3 Code", - width: 1100, - height: 780, - minWidth: 840, - minHeight: 620, - show: false, - modal: false, - frame: true, - transparent: false, - backgroundColor: "#101010", - icon: {} as Electron.NativeImage, - webPreferences: { - preload: "/tmp/preload.js", - partition: "persist:t3code-preview-test", - sandbox: true, - contextIsolation: true, - nodeIntegration: false, - webviewTag: true, - spellcheck: true, - }, - } satisfies Electron.BrowserWindowConstructorOptions; - const electronWindow = yield* ElectronWindow.ElectronWindow; - - const error = yield* electronWindow.create(options).pipe(Effect.flip); - - assert.instanceOf(error, ElectronWindow.ElectronWindowCreateError); - assert.deepEqual(error.options, { - title: "T3 Code", - width: 1100, - height: 780, - minWidth: 840, - minHeight: 620, - show: false, - modal: false, - frame: true, - transparent: false, - backgroundColor: "#101010", - webPreferences: { - preload: "/tmp/preload.js", - partition: "persist:t3code-preview-test", - backgroundThrottling: null, - sandbox: true, - contextIsolation: true, - nodeIntegration: false, - webviewTag: true, - }, - }); - assert.isFalse("icon" in error.options); - assert.isFalse("spellcheck" in error.options.webPreferences); - assert.strictEqual(error.cause, cause); - assert.equal(error.message, 'Failed to create Electron BrowserWindow "T3 Code" (1100x780).'); - assert.notInclude(error.message, cause.message); - assert.deepEqual(browserWindowMock.mock.calls, [[options]]); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("skips windows destroyed before appearance sync runs", () => - Effect.gen(function* () { - const liveWindow = makeBrowserWindow({ id: 1, destroyed: false }); - const destroyedWindow = makeBrowserWindow({ id: 2, destroyed: true }); - getAllWindowsMock.mockReturnValue([destroyedWindow, liveWindow]); - - const syncedWindows: Electron.BrowserWindow[] = []; - const electronWindow = yield* ElectronWindow.ElectronWindow; - yield* electronWindow.syncAllAppearance((window) => - Effect.sync(() => { - syncedWindows.push(window); - }), - ); - - assert.deepEqual(syncedWindows, [liveWindow]); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("preserves window enumeration failures as structured defects", () => - Effect.gen(function* () { - const cause = new Error("window enumeration failed"); - getAllWindowsMock.mockImplementationOnce(() => { - throw cause; - }); - - const electronWindow = yield* ElectronWindow.ElectronWindow; - const exit = yield* Effect.exit(electronWindow.currentMainOrFirst); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); - assert.equal(error.operation, "list-windows"); - assert.equal(error.platform, "linux"); - assert.isNull(error.windowId); - assert.isNull(error.channel); - assert.strictEqual(error.cause, cause); - assert.notInclude(error.message, cause.message); - } - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("preserves reveal failures with the target window", () => - Effect.gen(function* () { - const cause = new Error("window restore failed"); - const window = { - id: 41, - isDestroyed: vi.fn(() => false), - isMinimized: vi.fn(() => true), - restore: vi.fn(() => { - throw cause; - }), - } as unknown as Electron.BrowserWindow; - - const electronWindow = yield* ElectronWindow.ElectronWindow; - const exit = yield* Effect.exit(electronWindow.reveal(window)); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); - assert.equal(error.operation, "reveal-window"); - assert.equal(error.windowId, 41); - assert.isNull(error.channel); - assert.strictEqual(error.cause, cause); - } - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("uses native Windows activation without querying the foreground window", () => - Effect.gen(function* () { - const operations: Array = []; - appFocusMock.mockImplementation(() => operations.push("app-focus")); - activateWindowsForegroundMock.mockImplementation(async () => { - operations.push("native-activation"); - }); - const window = { - ...makeWindowsRevealWindow(), - show: vi.fn(() => operations.push("show")), - moveTop: vi.fn(() => operations.push("move-top")), - focus: vi.fn(() => operations.push("focus")), - } as unknown as Electron.BrowserWindow; - const electronWindow = yield* ElectronWindow.ElectronWindow; - yield* electronWindow.prepareReveal(window); - - yield* electronWindow.reveal(window); - - assert.deepEqual(operations, ["app-focus", "show", "move-top", "focus", "native-activation"]); - assert.lengthOf(loadWindowsForegroundApiMock.mock.calls, 0); - assert.lengthOf(windowsForegroundFocusMock.mock.calls, 0); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect("focuses the exact T3 window before activating from a shell-hosted app", () => - Effect.gen(function* () { - const operations: Array = []; - shellHostedForegroundMock.mockResolvedValue(true); - windowsForegroundFocusMock.mockImplementation(async () => { - operations.push("native-focus"); - return true; - }); - activateWindowsForegroundMock.mockImplementation(async () => { - operations.push("native-activation"); - }); - const window = { - ...makeWindowsRevealWindow(), - show: vi.fn(() => operations.push("show")), - moveTop: vi.fn(() => operations.push("move-top")), - focus: vi.fn(() => operations.push("focus")), - } as unknown as Electron.BrowserWindow; - appFocusMock.mockImplementation(() => operations.push("app-focus")); - const electronWindow = yield* ElectronWindow.ElectronWindow; - yield* electronWindow.prepareReveal(window); - - yield* electronWindow.reveal(window); - - assert.deepEqual(operations, [ - "app-focus", - "show", - "move-top", - "focus", - "native-focus", - "native-activation", - ]); - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 1); - assert.deepEqual(windowsForegroundFocusMock.mock.calls, [ - [ - { - windowId: 41, - processId: process.pid, - title: "T3 Code (Dev)", - bounds: { x: 100, y: 50, width: 1_200, height: 800 }, - contentBounds: { x: 108, y: 50, width: 1_184, height: 792 }, - }, - ], - ]); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect("prepares the exact T3 window before a capture overlay", () => - Effect.gen(function* () { - windowsForegroundPrepareMock.mockResolvedValue(true); - const window = makeWindowsRevealWindow(); - const electronWindow = yield* ElectronWindow.ElectronWindow; - - const prepared = yield* electronWindow.prepareReveal( - window as unknown as Electron.BrowserWindow, - ); - - assert.isTrue(prepared); - assert.deepEqual(windowsForegroundPrepareMock.mock.calls, [ - [ - { - windowId: 41, - processId: process.pid, - title: "T3 Code (Dev)", - bounds: { x: 100, y: 50, width: 1_200, height: 800 }, - contentBounds: { x: 108, y: 50, width: 1_184, height: 792 }, - }, - ], - ]); - assert.lengthOf(windowsForegroundFocusMock.mock.calls, 0); - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 0); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect.each([4, 8])( - "skips the focus worker only when the foreground matches the %i-byte HWND", - (handleBytes) => - Effect.gen(function* () { - const window = makeWindowsRevealWindow(); - activateWindowsForegroundMock.mockRejectedValueOnce( - new Error("Windows initially refused foreground activation"), - ); - const hwnd = handleBytes === 4 ? 0xf123_4567n : 0x1_f123_4567n; - const handle = Buffer.alloc(handleBytes); - if (handleBytes === 4) handle.writeUInt32LE(Number(hwnd)); - else handle.writeBigUInt64LE(hwnd); - window.getNativeWindowHandle.mockReturnValue(handle); - foregroundWindowMock.mockReturnValue(hwnd); - const electronWindow = yield* ElectronWindow.ElectronWindow; - yield* electronWindow.prepareReveal(window as unknown as Electron.BrowserWindow); - window.getTitle.mockClear(); - - yield* electronWindow.reveal(window as unknown as Electron.BrowserWindow); - - assert.lengthOf(foregroundWindowMock.mock.calls, 1); - assert.lengthOf(windowsForegroundFocusMock.mock.calls, 0); - assert.lengthOf(window.getTitle.mock.calls, 0); - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 2); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect.each([42n, 0n])( - "focuses through the worker when the foreground HWND is %s instead of the target", - (foreground) => - Effect.gen(function* () { - const window = makeWindowsRevealWindow(); - activateWindowsForegroundMock.mockRejectedValueOnce( - new Error("Windows initially refused foreground activation"), - ); - foregroundWindowMock.mockReturnValue(foreground); - const electronWindow = yield* ElectronWindow.ElectronWindow; - yield* electronWindow.prepareReveal(window as unknown as Electron.BrowserWindow); - - yield* electronWindow.reveal(window as unknown as Electron.BrowserWindow); - - assert.lengthOf(windowsForegroundFocusMock.mock.calls, 1); - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 2); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect("continues native focus when the foreground query fails", () => - Effect.gen(function* () { - const window = makeWindowsRevealWindow(); - activateWindowsForegroundMock.mockRejectedValueOnce( - new Error("Windows initially refused foreground activation"), - ); - loadWindowsForegroundApiMock.mockRejectedValue(new Error("Foreground query unavailable")); - const electronWindow = yield* ElectronWindow.ElectronWindow; - yield* electronWindow.prepareReveal(window as unknown as Electron.BrowserWindow); - - yield* electronWindow.reveal(window as unknown as Electron.BrowserWindow); - - assert.lengthOf(windowsForegroundFocusMock.mock.calls, 1); - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 2); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect("does not fail reveal when native focus rejects", () => - Effect.gen(function* () { - const window = makeWindowsRevealWindow(); - activateWindowsForegroundMock.mockRejectedValueOnce( - new Error("Windows initially refused foreground activation"), - ); - windowsForegroundFocusMock.mockRejectedValue(new Error("Focus rejected")); - const electronWindow = yield* ElectronWindow.ElectronWindow; - yield* electronWindow.prepareReveal(window as unknown as Electron.BrowserWindow); - - yield* electronWindow.reveal(window as unknown as Electron.BrowserWindow); - - assert.lengthOf(windowsForegroundFocusMock.mock.calls, 1); - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 2); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect("fails reveal when Windows refuses foreground activation", () => - Effect.gen(function* () { - const cause = new Error("Windows refused foreground activation"); - const window = makeWindowsRevealWindow(); - activateWindowsForegroundMock.mockRejectedValue(cause); - const electronWindow = yield* ElectronWindow.ElectronWindow; - yield* electronWindow.prepareReveal(window as unknown as Electron.BrowserWindow); - - const exit = yield* Effect.exit( - electronWindow.reveal(window as unknown as Electron.BrowserWindow), - ); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); - assert.equal(error.operation, "reveal-window"); - assert.strictEqual(error.cause, cause); - } - assert.deepEqual(activateWindowsForegroundMock.mock.calls, [ - [window.getNativeWindowHandle.mock.results[0]?.value], - [window.getNativeWindowHandle.mock.results[1]?.value], - ]); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - it.effect("cancels native focus when destroyed during the foreground query", () => Effect.gen(function* () { const window = makeWindowsRevealWindow(); @@ -474,35 +132,6 @@ describe("ElectronWindow", () => { }).pipe(Effect.provide(layerTestFor("win32"))), ); - it.effect("preserves message delivery failures with window and channel context", () => - Effect.gen(function* () { - const cause = new Error("renderer send failed"); - const window = { - id: 42, - isDestroyed: vi.fn(() => false), - webContents: { - send: vi.fn(() => { - throw cause; - }), - }, - } as unknown as Electron.BrowserWindow; - getAllWindowsMock.mockReturnValueOnce([window]); - - const electronWindow = yield* ElectronWindow.ElectronWindow; - const exit = yield* Effect.exit(electronWindow.sendAll("desktop:update", { ready: true })); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); - assert.equal(error.operation, "send-window-message"); - assert.equal(error.windowId, 42); - assert.equal(error.channel, "desktop:update"); - assert.strictEqual(error.cause, cause); - } - }).pipe(Effect.provide(layerTest)), - ); - it.effect("preserves destroy failures and continues with later windows", () => Effect.gen(function* () { const cause = new Error("window destroy failed"); @@ -533,50 +162,4 @@ describe("ElectronWindow", () => { assert.equal(vi.mocked(laterWindow.destroy).mock.calls.length, 1); }).pipe(Effect.provide(layerTest)), ); - it.effect("an ordinary reveal on Windows does not touch the Win32 foreground helpers", () => - Effect.gen(function* () { - const window = makeWindowsRevealWindow(); - const electronWindow = yield* ElectronWindow.ElectronWindow; - - yield* electronWindow.reveal(window as unknown as Electron.BrowserWindow); - - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 0); - assert.lengthOf(windowsForegroundFocusMock.mock.calls, 0); - assert.lengthOf(shellHostedForegroundMock.mock.calls, 0); - assert.lengthOf(startWindowsForegroundFocusThreadMock.mock.calls, 0); - assert.lengthOf(window.focus.mock.calls, 1); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect("a capture reveal on Windows uses the Win32 path only once", () => - Effect.gen(function* () { - const window = makeWindowsRevealWindow(); - const electronWindow = yield* ElectronWindow.ElectronWindow; - - yield* electronWindow.prepareReveal(window as unknown as Electron.BrowserWindow); - yield* electronWindow.reveal(window as unknown as Electron.BrowserWindow); - yield* electronWindow.reveal(window as unknown as Electron.BrowserWindow); - - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 1); - assert.lengthOf(window.focus.mock.calls, 2); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect("starts the Windows focus worker lazily and closes it with the layer", () => - Effect.gen(function* () { - yield* ElectronWindow.ElectronWindow.pipe(Effect.provide(layerTestFor("win32"))); - assert.lengthOf(startWindowsForegroundFocusThreadMock.mock.calls, 0); - assert.lengthOf(windowsForegroundCloseMock.mock.calls, 0); - - yield* Effect.gen(function* () { - const electronWindow = yield* ElectronWindow.ElectronWindow; - const window = makeWindowsRevealWindow() as unknown as Electron.BrowserWindow; - yield* electronWindow.prepareReveal(window); - yield* electronWindow.prepareReveal(window); - assert.lengthOf(startWindowsForegroundFocusThreadMock.mock.calls, 1); - assert.lengthOf(windowsForegroundCloseMock.mock.calls, 0); - }).pipe(Effect.provide(layerTestFor("win32"))); - assert.lengthOf(windowsForegroundCloseMock.mock.calls, 1); - }), - ); }); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index a7ab05303f72..993e930678ed 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -324,8 +324,9 @@ export const make = Effect.gen(function* () { cause, }), }).pipe(Effect.orDie), - sendAll: (channel, ...args) => - Effect.gen(function* () { + sendAll: + /* Window broadcasts run for streamed updates; avoid a span per broadcast. */ + Effect.fnUntraced(function* (channel, ...args) { for (const window of yield* listWindows) { if (yield* isWindowDestroyed(window)) { continue; diff --git a/apps/desktop/src/electron/WindowsForeground.test.ts b/apps/desktop/src/electron/WindowsForeground.test.ts index 3ac4b408f376..60975495976c 100644 --- a/apps/desktop/src/electron/WindowsForeground.test.ts +++ b/apps/desktop/src/electron/WindowsForeground.test.ts @@ -3,7 +3,6 @@ import { vi } from "vite-plus/test"; import { activateWindowsForegroundWithApi, - isWindowsShellHostedForegroundWithApi, type WindowsForegroundApi, } from "./WindowsForeground.ts"; @@ -55,66 +54,10 @@ describe("Windows foreground activation", () => { assert.deepEqual(api.setForegroundWindow.mock.calls, [[41n]]); }); - it("does not disturb input queues when T3 is already foreground", () => { - const api = makeApi({ foregroundWindow: 41n }); - - assert.isTrue(activateWindowsForegroundWithApi(nativeHandle(41n), api)); - - assert.lengthOf(api.getCurrentThreadId.mock.calls, 0); - assert.lengthOf(api.attachThreadInput.mock.calls, 0); - assert.lengthOf(api.setForegroundWindow.mock.calls, 0); - }); - - it("does not attach a thread to itself", () => { - const api = makeApi({ currentThreadId: 20, foregroundThreadId: 20 }); - - assert.isTrue(activateWindowsForegroundWithApi(nativeHandle(41n), api)); - - assert.lengthOf(api.attachThreadInput.mock.calls, 0); - assert.deepEqual(api.setForegroundWindow.mock.calls, [[41n]]); - }); - - it("uses SetForegroundWindow's result as the activation receipt", () => { - const api = makeApi({ activated: false }); - - assert.isFalse(activateWindowsForegroundWithApi(nativeHandle(41n), api)); - - assert.deepEqual(api.attachThreadInput.mock.calls, [ - [10, 20, true], - [10, 20, false], - ]); - }); - - it("still asks Windows directly when the input queues cannot be attached", () => { - const api = makeApi({ attached: false }); - - assert.isTrue(activateWindowsForegroundWithApi(nativeHandle(41n), api)); - - assert.deepEqual(api.attachThreadInput.mock.calls, [[10, 20, true]]); - assert.deepEqual(api.setForegroundWindow.mock.calls, [[41n]]); - }); - it("rejects malformed native handles", () => { const api = makeApi({}); assert.throws(() => activateWindowsForegroundWithApi(Buffer.alloc(6), api)); assert.lengthOf(api.getForegroundWindow.mock.calls, 0); }); - - it("recognizes a shell-hosted foreground window", () => { - const api = makeApi({}); - api.getWindowClassName.mockReturnValue("ApplicationFrameWindow"); - - assert.isTrue(isWindowsShellHostedForegroundWithApi(api)); - assert.deepEqual(api.getWindowClassName.mock.calls, [[99n]]); - }); - - it("does not classify ordinary or missing foreground windows as shell-hosted", () => { - const ordinary = makeApi({}); - const missing = makeApi({ foregroundWindow: 0n }); - - assert.isFalse(isWindowsShellHostedForegroundWithApi(ordinary)); - assert.isFalse(isWindowsShellHostedForegroundWithApi(missing)); - assert.lengthOf(missing.getWindowClassName.mock.calls, 0); - }); }); diff --git a/apps/desktop/src/electron/WindowsForeground.ts b/apps/desktop/src/electron/WindowsForeground.ts index 47ecd7fd71f8..a124697e15bf 100644 --- a/apps/desktop/src/electron/WindowsForeground.ts +++ b/apps/desktop/src/electron/WindowsForeground.ts @@ -54,7 +54,7 @@ export function activateWindowsForegroundWithApi( } } -export function isWindowsShellHostedForegroundWithApi(api: WindowsForegroundApi): boolean { +function isWindowsShellHostedForegroundWithApi(api: WindowsForegroundApi): boolean { const foregroundWindow = api.getForegroundWindow(); return ( foregroundWindow !== 0n && diff --git a/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts b/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts index 46193ca2c341..ac15b145e0c6 100644 --- a/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts +++ b/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts @@ -68,33 +68,3 @@ it("fails pending focus when the helper exits", async () => { assert.lengthOf(workerConstructorMock.mock.calls, 2); thread.close(); }); - -it("replaces a timed-out helper before accepting more work", async () => { - vi.useFakeTimers(); - const replacement = makeWorker(); - workerConstructorMock.mockReturnValueOnce(worker).mockReturnValueOnce(replacement); - const thread = startWindowsForegroundFocusThread("focus.cjs"); - - try { - worker.emit("message", "ready"); - const focused = thread.focus(target); - await vi.advanceTimersByTimeAsync(1_000); - - assert.isFalse(await focused); - assert.lengthOf(worker.terminate.mock.calls, 1); - assert.lengthOf(workerConstructorMock.mock.calls, 2); - - replacement.emit("message", "ready"); - const prepared = thread.prepare(target); - assert.deepEqual(replacement.postMessage.mock.calls[0]?.[0], { - type: "prepare", - requestId: 2, - target, - }); - replacement.emit("message", { type: "result", requestId: 2, focused: true }); - assert.isTrue(await prepared); - } finally { - thread.close(); - vi.useRealTimers(); - } -}); diff --git a/apps/desktop/src/electron/WindowsForegroundFocusWorker.test.ts b/apps/desktop/src/electron/WindowsForegroundFocusWorker.test.ts deleted file mode 100644 index 4c07e4671fb2..000000000000 --- a/apps/desktop/src/electron/WindowsForegroundFocusWorker.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { assert, beforeEach, it, vi } from "vite-plus/test"; - -const { byPid, list, on, postMessage } = vi.hoisted(() => ({ - byPid: vi.fn(), - list: vi.fn(), - on: vi.fn(), - postMessage: vi.fn(), -})); - -vi.mock("node:worker_threads", () => ({ parentPort: { on, postMessage } })); -vi.mock("@crowecawcaw/xa11y", () => ({ App: { byPid, list } })); - -beforeEach(() => { - vi.resetModules(); - vi.resetAllMocks(); -}); - -it.each(["lookup", "children"])("falls back to enumeration after a failed %s", async (failure) => { - const error = new Error("Unavailable"); - if (failure === "lookup") byPid.mockRejectedValue(error); - else byPid.mockResolvedValue({ children: () => Promise.reject(error) }); - const bounds = { x: 0, y: 0, width: 800, height: 600 }; - const focus = vi.fn().mockResolvedValue(undefined); - list.mockResolvedValue([{ pid: 42, asElement: () => ({ name: "T3", bounds, focus }) }]); - await import("./WindowsForegroundFocusWorker.ts"); - await vi.dynamicImportSettled(); - const result = Promise.withResolvers(); - postMessage.mockImplementation(result.resolve); - on.mock.calls[0]![1]({ - type: "focus", - requestId: 1, - target: { windowId: 1, processId: 42, title: "T3", bounds, contentBounds: bounds }, - }); - assert.deepEqual(await result.promise, { type: "result", requestId: 1, focused: true }); - assert.lengthOf(focus.mock.calls, 1); -}); diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts deleted file mode 100644 index e90491f1f4e7..000000000000 --- a/apps/desktop/src/ipc/DesktopIpc.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import { vi } from "vite-plus/test"; - -import * as DesktopIpc from "./DesktopIpc.ts"; - -const invokeMethod: DesktopIpc.DesktopIpcMethod = { - channel: "desktop.test.invoke", - handler: () => Effect.void, -}; - -const syncMethod: DesktopIpc.DesktopSyncIpcMethod = { - channel: "desktop.test.sync", - handler: () => Effect.void, -}; - -function makeIpcMain( - overrides: Partial = {}, -): DesktopIpc.DesktopIpcMain { - return { - removeHandler: vi.fn(), - handle: vi.fn(), - removeAllListeners: vi.fn(), - on: vi.fn(), - ...overrides, - }; -} - -describe("DesktopIpc", () => { - it.effect("preserves invoke registration context and cause", () => - Effect.gen(function* () { - const cause = new Error("invoke registration failed"); - const ipcMain = makeIpcMain({ - handle: () => { - throw cause; - }, - }); - const ipc = DesktopIpc.make(ipcMain); - - const error = yield* Effect.flip(Effect.scoped(ipc.handle(invokeMethod))); - - assert.instanceOf(error, DesktopIpc.DesktopIpcRegistrationError); - assert.strictEqual(error.handlerKind, "invoke"); - assert.strictEqual(error.channel, invokeMethod.channel); - assert.strictEqual(error.cause, cause); - assert.include(error.message, "invoke"); - assert.include(error.message, invokeMethod.channel); - assert.notInclude(error.message, cause.message); - }), - ); - - it.effect("forwards the invoke sender to the method", () => - Effect.gen(function* () { - let listener: DesktopIpc.DesktopIpcHandleListener | undefined; - const ipc = DesktopIpc.make( - makeIpcMain({ - handle: (_channel, registered) => { - listener = registered; - }, - }), - ); - const sender = { sender: { id: 7 } }; - let received: DesktopIpc.DesktopIpcInvokeEvent | undefined; - - yield* Effect.scoped( - Effect.gen(function* () { - yield* ipc.handle({ - channel: "desktop.test.sender", - handler: (_raw, event) => - Effect.sync(() => { - received = event; - }), - }); - yield* Effect.promise(async () => listener!(sender, undefined)); - }), - ); - - assert.strictEqual(received, sender); - }), - ); - - it.effect("preserves sync unregistration context and cause in the finalizer defect", () => - Effect.gen(function* () { - const cause = new Error("sync unregistration failed"); - let removeCount = 0; - const ipcMain = makeIpcMain({ - removeAllListeners: () => { - removeCount += 1; - if (removeCount === 2) throw cause; - }, - }); - const ipc = DesktopIpc.make(ipcMain); - - const exit = yield* Effect.exit(Effect.scoped(ipc.handleSync(syncMethod))); - - assert.isTrue(exit._tag === "Failure"); - if (exit._tag === "Success") return; - const error = Cause.squash(exit.cause); - assert.instanceOf(error, DesktopIpc.DesktopIpcUnregistrationError); - assert.strictEqual(error.handlerKind, "sync"); - assert.strictEqual(error.channel, syncMethod.channel); - assert.strictEqual(error.cause, cause); - assert.notInclude(error.message, cause.message); - }), - ); -}); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index caed223e90e8..2d67aa56e6cc 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -41,6 +41,7 @@ import { getAppBranding, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, + getRelayTelemetryEnabled, getSystemLocale, getWindowFullscreenState, openExternal, @@ -48,6 +49,7 @@ import { checkSystemPermission, pasteAsText, probeRemoteEditors, + revealWindow, pickFolder, pickProjectFavicon, pickThemeFiles, @@ -94,6 +96,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(takeLegacyLocalStorage); yield* ipc.handle(completeLegacyLocalStorage); yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(getRelayTelemetryEnabled); yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); @@ -150,6 +153,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(openSystemSettings); yield* ipc.handle(checkSystemPermission); yield* ipc.handle(pasteAsText); + yield* ipc.handle(revealWindow); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); @@ -160,7 +164,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(installCliCommand); yield* ipc.handle(uninstallCliCommand); for (const previewMethod of PreviewIpc.methods) { - yield* ipc.handle(previewMethod); + // Explicit types preserve the error/service union of these heterogeneous preview methods. + yield* ipc.handle< + Effect.Error>, + Effect.Services> + >(previewMethod); } yield* ipc.handle(PreviewIpc.listBrowserImportSources); yield* ipc.handle(PreviewIpc.importBrowserCookies); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index a6b617385b57..9b11a2283a27 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -9,6 +9,7 @@ export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; +export const REVEAL_WINDOW_CHANNEL = "desktop:reveal-window"; export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const WEB_LINK_OPEN_CHANNEL = "desktop:web-link-open"; export const WEB_LINK_READY_CHANNEL = "desktop:web-link-ready"; @@ -29,6 +30,7 @@ export const CLI_COMMAND_GET_STATE_CHANNEL = "desktop:cli-command-get-state"; export const CLI_COMMAND_INSTALL_CHANNEL = "desktop:cli-command-install"; export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; +export const GET_RELAY_TELEMETRY_ENABLED_CHANNEL = "desktop:get-relay-telemetry-enabled"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; diff --git a/apps/desktop/src/ipc/methods/notificationBadge.test.ts b/apps/desktop/src/ipc/methods/notificationBadge.test.ts index 40d1df6a3366..7126b1cd3194 100644 --- a/apps/desktop/src/ipc/methods/notificationBadge.test.ts +++ b/apps/desktop/src/ipc/methods/notificationBadge.test.ts @@ -28,7 +28,7 @@ vi.mock("electron", () => ({ import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; -import { applyNotificationBadge, installNotificationBadge } from "./notificationBadge.ts"; +import { installNotificationBadge } from "./notificationBadge.ts"; const badge = { count: 2, image: "data:image/png;base64,aGVsbG8=" }; @@ -42,51 +42,6 @@ beforeEach(() => { native.listeners.clear(); }); -it.each(["darwin", "linux"] as const)("sets and clears the native %s count", (platform) => { - applyNotificationBadge(platform, badge); - applyNotificationBadge(platform, { count: 0, image: null }); - expect(native.setBadgeCount.mock.calls).toEqual([[2], [0]]); - expect(native.createFromDataURL).not.toHaveBeenCalled(); -}); - -it("sets and clears the Windows taskbar overlay", () => { - applyNotificationBadge("win32", badge); - expect(native.setOverlayIcon).toHaveBeenLastCalledWith( - native.image, - "2 threads with new notifications", - ); - applyNotificationBadge("win32", { count: 0, image: null }); - expect(native.setOverlayIcon).toHaveBeenLastCalledWith(null, ""); -}); - -it.each(["win32", "darwin", "linux"] as const)( - "rejects a late positive count while %s is focused", - (platform) => { - native.getFocusedWindow.mockReturnValue({}); - applyNotificationBadge(platform, badge); - if (platform === "win32") expect(native.setOverlayIcon).toHaveBeenCalledWith(null, ""); - else expect(native.setBadgeCount).toHaveBeenCalledWith(0); - expect(native.createFromDataURL).not.toHaveBeenCalled(); - }, -); - -it("ignores destroyed windows and clears invalid images", () => { - native.isDestroyed.mockReturnValue(true); - applyNotificationBadge("win32", badge); - expect(native.setOverlayIcon).not.toHaveBeenCalled(); - native.isDestroyed.mockReturnValue(false); - native.image.isEmpty.mockReturnValue(true); - applyNotificationBadge("win32", badge); - expect(native.setOverlayIcon.mock.calls[0]?.[0]).toBeNull(); -}); - -it("keeps notifications working when the native badge API fails", () => { - native.setBadgeCount.mockImplementation(() => { - throw new Error("Unavailable"); - }); - expect(() => applyNotificationBadge("linux", badge)).not.toThrow(); -}); - it.effect("validates IPC and clears on native focus, quit, and disposal", () => Effect.gen(function* () { const handlers = new Map(); diff --git a/apps/desktop/src/ipc/methods/notificationBadge.ts b/apps/desktop/src/ipc/methods/notificationBadge.ts index 952745b4b7c6..39eeeeeb1f30 100644 --- a/apps/desktop/src/ipc/methods/notificationBadge.ts +++ b/apps/desktop/src/ipc/methods/notificationBadge.ts @@ -17,7 +17,7 @@ const NotificationBadge = Schema.Struct({ ), }); -export function applyNotificationBadge( +function applyNotificationBadge( platform: NodeJS.Platform, { count, image }: typeof NotificationBadge.Type, ): void { diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index 225139c04741..802f2f3fb18b 100644 --- a/apps/desktop/src/ipc/methods/preview.test.ts +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -2,18 +2,17 @@ import { it as effectIt } from "@effect/vitest"; import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID, - PreviewAutomationStatus, + PreviewCaptureFailure, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import * as PreviewManager from "../../preview/Manager.ts"; -import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts"; import * as PreviewIpc from "./preview.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; const { fromPartition } = vi.hoisted(() => ({ fromPartition: vi.fn(() => { @@ -33,16 +32,85 @@ vi.mock("electron", () => ({ }, })); +const decodeCaptureFailure = Schema.decodeUnknownEffect(PreviewCaptureFailure); + describe("preview IPC methods", () => { + const invokeCapture = ( + method: typeof PreviewIpc.startRecording | typeof PreviewIpc.captureScreenshot, + manager: Partial, + ) => + Effect.scoped( + Effect.gen(function* () { + let listener: DesktopIpc.DesktopIpcHandleListener | undefined; + const ipc = DesktopIpc.make({ + removeHandler: vi.fn(), + handle: (_channel, registered) => { + listener = registered; + }, + removeAllListeners: vi.fn(), + on: vi.fn(), + }); + yield* ipc.handle(method); + return yield* Effect.promise(async () => + structuredClone(await listener!({ sender: { id: 7 } }, { tabId: "capture-tab" })), + ); + }).pipe( + Effect.provideService( + PreviewManager.PreviewManager, + manager as PreviewManager.PreviewManager["Service"], + ), + Effect.provideService(DesktopClientSettings.DesktopClientSettings, { + get: Effect.succeedNone, + set: () => Effect.void, + }), + ), + ); + + effectIt.effect.each([ + { + cause: new Error("Current display surface not available for capture"), + reason: "capture-unavailable", + guidance: ["target tab", "unlocked", "display is on", "window is restored"], + }, + { + cause: new Cause.TimeoutError(), + reason: "capture-timeout", + guidance: ["timed out during capture-page"], + }, + { + cause: new Error("Current display surface not available for capture: private page data"), + reason: "capture-failed", + guidance: ["Preview capture failed during capture-page"], + }, + ])( + "transports a safe $reason screenshot diagnostic through registered IPC", + ({ cause, reason, guidance }) => + Effect.gen(function* () { + const result = yield* invokeCapture(PreviewIpc.captureScreenshot, { + captureScreenshot: (tabId) => + Effect.fail( + new PreviewManager.PreviewOperationError({ + operation: "captureScreenshot.capturePage", + tabId, + artifactPath: "/private/capture.png", + cause, + }), + ), + }); + expect(result).toEqual({ + _tag: "PreviewCaptureFailure", + reason, + stage: "capture-page", + }); + const failure = yield* decodeCaptureFailure(result); + for (const phrase of guidance) expect(failure.message).toContain(phrase); + }), + ); + beforeEach(() => { fromPartition.mockClear(); }); - it("does not access the Electron session while the module loads", async () => { - await expect(import("./preview.ts")).resolves.toBeDefined(); - expect(fromPartition).not.toHaveBeenCalled(); - }); - it("derives distinct partition scopes when identifiers contain the delimiter", () => { const first = PreviewIpc.resolvePartitionScope("a", "b::c"); const second = PreviewIpc.resolvePartitionScope("a::b", "c"); @@ -52,18 +120,6 @@ describe("preview IPC methods", () => { expect(first.scope).not.toBe(second.scope); }); - it("preserves lone surrogates without collapsing them to replacement characters", () => { - const highSurrogate = PreviewIpc.resolvePartitionScope("environment", "profile-\ud800"); - const lowSurrogate = PreviewIpc.resolvePartitionScope("environment", "profile-\udc00"); - const replacement = PreviewIpc.resolvePartitionScope("environment", "profile-�"); - - expect(highSurrogate.scope).toBe('["environment","profile-\\ud800"]'); - expect(lowSurrogate.scope).toBe('["environment","profile-\\udc00"]'); - expect(highSurrogate.scope).not.toBe(lowSurrogate.scope); - expect(highSurrogate.scope).not.toBe(replacement.scope); - expect(lowSurrogate.scope).not.toBe(replacement.scope); - }); - it("keeps the legacy default partition scope and incognito persistence", () => { expect(PreviewIpc.resolvePartitionScope("environment::legacy", undefined)).toEqual({ scope: "environment::legacy", @@ -80,67 +136,4 @@ describe("preview IPC methods", () => { namespace: "profile", }); }); - - effectIt.effect("targets imports at the same partition tuple as the renderer", () => { - const received: Array[0]> = - []; - const browserImport = BrowserImport.BrowserImport.of({ - listSources: Effect.succeed([]), - importCookies: (input) => - Effect.sync(() => { - received.push(input); - return { imported: 0, skipped: 0, skippedDomains: [] }; - }), - }); - const request = (environmentId: string, targetProfileId: string) => - PreviewIpc.importBrowserCookies.handler({ - environmentId, - sourceId: "helium", - sourceProfileDirectory: "Default", - targetProfileId, - }); - - return Effect.gen(function* () { - yield* request("a", "b"); - yield* request("a::b", DEFAULT_BROWSER_PROFILE_ID); - - expect(received[0]).toMatchObject(PreviewIpc.resolvePartitionScope("a", "b")); - expect(received[1]).toMatchObject( - PreviewIpc.resolvePartitionScope("a::b", DEFAULT_BROWSER_PROFILE_ID), - ); - expect(received[0]?.namespace).toBe("profile"); - expect(received[1]?.namespace).toBeUndefined(); - }).pipe(Effect.provideService(BrowserImport.BrowserImport, browserImport)); - }); - - effectIt.effect("rejects invalid webContents ids before resolving the preview service", () => - Effect.map( - PreviewIpc.registerWebview - .handler({ tabId: "tab-1", webContentsId: 0 }) - .pipe(Effect.provideService(PreviewManager.PreviewManager, null as never), Effect.exit), - (exit) => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isSuccess(exit)) return; - const error = Cause.findErrorOption(exit.cause); - expect(Option.isSome(error) && Schema.isSchemaError(error.value)).toBe(true); - expect(fromPartition).not.toHaveBeenCalled(); - }, - ), - ); - - it("keeps the public automation status tab id limit", () => { - const encode = Schema.encodeUnknownSync(PreviewAutomationStatus); - const tabId = "t".repeat(129); - - expect(() => - encode({ - available: false, - visible: true, - tabId, - url: null, - title: null, - loading: false, - }), - ).toThrow(); - }); }); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 74c6f85043d7..b659054540c2 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -19,6 +19,7 @@ import { DesktopPreviewTabInputSchema, DesktopPreviewWebviewConfigSchema, PreviewAnnotationSubmissionResultSchema, + PreviewCaptureFailure, DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID, PreviewForwardedShortcut, @@ -32,6 +33,7 @@ import * as NodeURL from "node:url"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts"; import * as PreviewManager from "../../preview/Manager.ts"; +import { normalizeCaptureFailure } from "../../preview/captureErrors.ts"; import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; import * as IpcChannels from "../channels.ts"; @@ -59,7 +61,7 @@ export const installPreviewEventForwarding = Effect.fn( ); }); -export const setForwardedShortcuts = DesktopIpc.makeIpcMethod({ +const setForwardedShortcuts = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_FORWARDED_SHORTCUTS_CHANNEL, payload: Schema.Array(PreviewForwardedShortcut).check(Schema.isMaxLength(MAX_KEYBINDINGS_COUNT)), result: Schema.Void, @@ -69,7 +71,7 @@ export const setForwardedShortcuts = DesktopIpc.makeIpcMethod({ }), }); -export const createTab = DesktopIpc.makeIpcMethod({ +const createTab = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, payload: DesktopPreviewCreateTabInputSchema, result: Schema.Void, @@ -84,7 +86,7 @@ export const createTab = DesktopIpc.makeIpcMethod({ }), }); -export const closeTab = DesktopIpc.makeIpcMethod({ +const closeTab = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, payload: DesktopPreviewTabInputSchema, result: Schema.Void, @@ -94,7 +96,7 @@ export const closeTab = DesktopIpc.makeIpcMethod({ }), }); -export const registerWebview = DesktopIpc.makeIpcMethod({ +const registerWebview = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, payload: DesktopPreviewRegisterWebviewInputSchema, result: Schema.Void, @@ -104,7 +106,7 @@ export const registerWebview = DesktopIpc.makeIpcMethod({ }), }); -export const navigate = DesktopIpc.makeIpcMethod({ +const navigate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_NAVIGATE_CHANNEL, payload: DesktopPreviewNavigateInputSchema, result: Schema.Void, @@ -132,42 +134,42 @@ const tabMethod = ( }), }); -export const goBack = tabMethod( +const goBack = tabMethod( IpcChannels.PREVIEW_GO_BACK_CHANNEL, "desktop.ipc.preview.goBack", (manager, tabId) => manager.goBack(tabId), ); -export const goForward = tabMethod( +const goForward = tabMethod( IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, "desktop.ipc.preview.goForward", (manager, tabId) => manager.goForward(tabId), ); -export const refresh = tabMethod( +const refresh = tabMethod( IpcChannels.PREVIEW_REFRESH_CHANNEL, "desktop.ipc.preview.refresh", (manager, tabId) => manager.refresh(tabId), ); -export const zoomIn = tabMethod( +const zoomIn = tabMethod( IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, "desktop.ipc.preview.zoomIn", (manager, tabId) => manager.zoomIn(tabId), ); -export const zoomOut = tabMethod( +const zoomOut = tabMethod( IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, "desktop.ipc.preview.zoomOut", (manager, tabId) => manager.zoomOut(tabId), ); -export const resetZoom = tabMethod( +const resetZoom = tabMethod( IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, "desktop.ipc.preview.resetZoom", (manager, tabId) => manager.resetZoom(tabId), ); -export const hardReload = tabMethod( +const hardReload = tabMethod( IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, "desktop.ipc.preview.hardReload", (manager, tabId) => manager.hardReload(tabId), ); -export const setZoomFactor = DesktopIpc.makeIpcMethod({ +const setZoomFactor = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_ZOOM_FACTOR_CHANNEL, payload: DesktopPreviewSetZoomFactorInputSchema, result: Schema.Void, @@ -176,7 +178,7 @@ export const setZoomFactor = DesktopIpc.makeIpcMethod({ yield* manager.setZoomFactor(tabId, zoomFactor); }), }); -export const setColorScheme = DesktopIpc.makeIpcMethod({ +const setColorScheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, payload: DesktopPreviewSetColorSchemeInputSchema, result: Schema.Void, @@ -185,7 +187,7 @@ export const setColorScheme = DesktopIpc.makeIpcMethod({ yield* manager.setColorScheme(tabId, colorScheme); }), }); -export const setAudioMuted = DesktopIpc.makeIpcMethod({ +const setAudioMuted = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, payload: DesktopPreviewSetAudioMutedInputSchema, result: Schema.Void, @@ -194,12 +196,12 @@ export const setAudioMuted = DesktopIpc.makeIpcMethod({ yield* manager.setAudioMuted(tabId, audioMuted); }), }); -export const openDevTools = tabMethod( +const openDevTools = tabMethod( IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, "desktop.ipc.preview.openDevTools", (manager, tabId) => manager.openDevTools(tabId), ); -export const cancelPickElement = tabMethod( +const cancelPickElement = tabMethod( IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, "desktop.ipc.preview.cancelPickElement", (manager, tabId) => manager.cancelPickElement(tabId), @@ -207,7 +209,8 @@ export const cancelPickElement = tabMethod( export const startRecording = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL, payload: DesktopPreviewTabInputSchema, - result: Schema.Void, + // Void encodes any value as undefined, so the tagged failure must be tried first. + result: Schema.Union([PreviewCaptureFailure, Schema.Void]), handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) { const manager = yield* PreviewManager.PreviewManager; const store = yield* DesktopClientSettings.DesktopClientSettings; @@ -216,26 +219,30 @@ export const startRecording = DesktopIpc.makeIpcMethod({ showKeyPresses: value.browserRecordingShowKeyPresses, showMousePresses: value.browserRecordingShowMousePresses, })); - yield* manager.startRecording(tabId, Option.getOrUndefined(options)); + return yield* manager + .startRecording(tabId, Option.getOrUndefined(options)) + .pipe( + Effect.catch((error) => Effect.succeed(normalizeCaptureFailure(error, "recording-start"))), + ); }), }); -export const stopRecording = tabMethod( +const stopRecording = tabMethod( IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, "desktop.ipc.preview.stopRecording", (manager, tabId) => manager.stopRecording(tabId), ); -export const openPictureInPicture = tabMethod( +const openPictureInPicture = tabMethod( IpcChannels.PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL, "desktop.ipc.preview.openPictureInPicture", (manager, tabId) => manager.openPictureInPicture(tabId), ); -export const closePictureInPicture = tabMethod( +const closePictureInPicture = tabMethod( IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL, "desktop.ipc.preview.closePictureInPicture", (manager, tabId) => manager.closePictureInPicture(tabId), ); -export const clearCookies = DesktopIpc.makeIpcMethod({ +const clearCookies = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, payload: DesktopPreviewClearDataInputSchema, result: Schema.Void, @@ -245,7 +252,7 @@ export const clearCookies = DesktopIpc.makeIpcMethod({ }), }); -export const clearCache = DesktopIpc.makeIpcMethod({ +const clearCache = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, payload: DesktopPreviewClearDataInputSchema, result: Schema.Void, @@ -301,7 +308,7 @@ const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartit return [yield* manager.getBrowserPartition(scope, persistent, namespace)]; }); -export const getPreviewConfig = DesktopIpc.makeIpcMethod({ +const getPreviewConfig = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, payload: DesktopPreviewConfigInputSchema, result: DesktopPreviewWebviewConfigSchema, @@ -354,7 +361,7 @@ export const importBrowserCookies = DesktopIpc.makeIpcMethod({ }), }); -export const setAnnotationTheme = DesktopIpc.makeIpcMethod({ +const setAnnotationTheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, payload: DesktopPreviewAnnotationThemeInputSchema, result: Schema.Void, @@ -364,7 +371,7 @@ export const setAnnotationTheme = DesktopIpc.makeIpcMethod({ }), }); -export const pickElement = DesktopIpc.makeIpcMethod({ +const pickElement = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, payload: DesktopPreviewTabInputSchema, result: Schema.NullOr(PreviewAnnotationSubmissionResultSchema), @@ -374,7 +381,7 @@ export const pickElement = DesktopIpc.makeIpcMethod({ }), }); -export const setAnnotationSendEnabled = DesktopIpc.makeIpcMethod({ +const setAnnotationSendEnabled = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_ANNOTATION_SEND_ENABLED_CHANNEL, payload: DesktopPreviewAnnotationSendEnabledInputSchema, result: Schema.Void, @@ -390,14 +397,16 @@ export const setAnnotationSendEnabled = DesktopIpc.makeIpcMethod({ export const captureScreenshot = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, payload: DesktopPreviewTabInputSchema, - result: DesktopPreviewScreenshotArtifactSchema, + result: Schema.Union([DesktopPreviewScreenshotArtifactSchema, PreviewCaptureFailure]), handler: Effect.fn("desktop.ipc.preview.captureScreenshot")(function* ({ tabId }) { const manager = yield* PreviewManager.PreviewManager; - return yield* manager.captureScreenshot(tabId); + return yield* manager + .captureScreenshot(tabId) + .pipe(Effect.catch((error) => Effect.succeed(normalizeCaptureFailure(error, "screenshot")))); }), }); -export const revealArtifact = DesktopIpc.makeIpcMethod({ +const revealArtifact = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, payload: DesktopPreviewArtifactInputSchema, result: Schema.Void, @@ -407,7 +416,7 @@ export const revealArtifact = DesktopIpc.makeIpcMethod({ }), }); -export const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({ +const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, payload: DesktopPreviewArtifactInputSchema, result: Schema.Void, @@ -417,7 +426,7 @@ export const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({ }), }); -export const saveRecording = DesktopIpc.makeIpcMethod({ +const saveRecording = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, payload: DesktopPreviewRecordingSaveInputSchema, result: DesktopPreviewRecordingArtifactSchema, diff --git a/apps/desktop/src/ipc/methods/snapShot.test.ts b/apps/desktop/src/ipc/methods/snapShot.test.ts index 138bcbc36d30..7e1ae602f979 100644 --- a/apps/desktop/src/ipc/methods/snapShot.test.ts +++ b/apps/desktop/src/ipc/methods/snapShot.test.ts @@ -1,22 +1,15 @@ import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as DesktopSnapShot from "../../snapShot/DesktopSnapShot.ts"; import { - checkSnapShotShortcut, - requestSnapShotPermissions, setupSnapShot, previewSnapShotConfig, applySnapShotConfig, - setSnapShotAnimationDestination, - setSnapShotShortcutSuppressed, - snapShotScreenFrame, snapShotRelativeFrame, } from "./snapShot.ts"; @@ -74,193 +67,6 @@ describe("window capture IPC", () => { ); }); - it.effect("cancelling custom file selection reads and writes nothing", () => { - let read = false; - return Effect.gen(function* () { - const preview = yield* previewSnapShotConfig.handler( - { operation: "install", chooseFile: true }, - { sender: { id: 7 } }, - ); - assert.isNull(preview); - assert.isFalse(read); - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(ElectronWindow.ElectronWindow, { - main: Effect.succeedSome({ webContents: { id: 7 } }), - } as ElectronWindow.ElectronWindow["Service"]), - Layer.succeed(DesktopSnapShot.DesktopSnapShot, { - state: Effect.succeed({ - linuxBackend: "niri", - shortcutConfigPath: "/config/niri/config.kdl", - }), - previewConfig: () => - Effect.sync(() => { - read = true; - return configPreview; - }), - } as unknown as DesktopSnapShot.DesktopSnapShot["Service"]), - Layer.succeed(ElectronDialog.ElectronDialog, { - pickFiles: () => Effect.succeed([]), - } as unknown as ElectronDialog.ElectronDialog["Service"]), - ), - ), - ); - }); - - it.effect("uses only the file returned by the native custom config picker", () => { - let path: string | undefined; - return Effect.gen(function* () { - yield* previewSnapShotConfig.handler( - { operation: "install", chooseFile: true }, - { sender: { id: 7 } }, - ); - assert.equal(path, "/chosen/config.kdl"); - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(ElectronWindow.ElectronWindow, { - main: Effect.succeedSome({ webContents: { id: 7 } }), - } as ElectronWindow.ElectronWindow["Service"]), - Layer.succeed(DesktopSnapShot.DesktopSnapShot, { - state: Effect.succeed({ linuxBackend: "niri" }), - previewConfig: (_: unknown, selected: string) => - Effect.sync(() => { - path = selected; - return configPreview; - }), - } as unknown as DesktopSnapShot.DesktopSnapShot["Service"]), - Layer.succeed(ElectronDialog.ElectronDialog, { - pickFiles: () => Effect.succeed(["/chosen/config.kdl"]), - } as unknown as ElectronDialog.ElectronDialog["Service"]), - ), - ), - ); - }); - - it("converts renderer viewport coordinates from the content origin using the window zoom", () => { - assert.deepEqual( - snapShotScreenFrame( - { x: 12, y: 20, width: 208, height: 112 }, - { x: 100, y: 80, width: 1_000, height: 700 }, - 1.25, - ), - { x: 115, y: 105, width: 260, height: 140 }, - ); - }); - - it.effect("forwards a trusted renderer animation destination in screen coordinates", () => { - let received: unknown; - const webContents = { id: 7, getZoomFactor: () => 1.25 }; - const layer = Layer.mergeAll( - Layer.succeed( - ElectronWindow.ElectronWindow, - ElectronWindow.ElectronWindow.of({ - main: Effect.succeedSome({ - getBounds: () => ({ x: 100, y: 80, width: 1_000, height: 700 }), - getContentBounds: () => ({ x: 100, y: 118, width: 1_000, height: 662 }), - webContents, - }), - } as ElectronWindow.ElectronWindow["Service"]), - ), - Layer.succeed( - DesktopSnapShot.DesktopSnapShot, - DesktopSnapShot.DesktopSnapShot.of({ - setAnimationDestination: (id: string, destination: unknown) => - Effect.sync(() => { - received = { id, destination }; - }), - } as unknown as DesktopSnapShot.DesktopSnapShot["Service"]), - ), - ); - - return Effect.gen(function* () { - yield* setSnapShotAnimationDestination.handler( - { - id: "12345678-1234-1234-1234-123456789abc", - viewportFrame: { x: 12, y: 20, width: 208, height: 112 }, - backgroundColor: "rgb(20, 20, 20)", - borderColor: "rgba(80, 80, 80, 0.8)", - borderWidth: 1, - cornerRadius: 8, - details: { - appName: "T3 Code", - windowTitle: "Capture animation", - appIconDataUrl: "data:image/png;base64,aWNvbg==", - }, - }, - { sender: webContents }, - ); - assert.deepEqual(received, { - id: "12345678-1234-1234-1234-123456789abc", - destination: { - relativeFrame: { x: 15 / 1000, y: 25 / 662, width: 260 / 1000, height: 140 / 662 }, - frame: { x: 115, y: 143, width: 260, height: 140 }, - backgroundColor: "rgb(20, 20, 20)", - borderColor: "rgba(80, 80, 80, 0.8)", - borderWidth: 1.25, - cornerRadius: 10, - scaleFactor: 1.25, - details: { - appName: "T3 Code", - windowTitle: "Capture animation", - appIconDataUrl: "data:image/png;base64,aWNvbg==", - }, - }, - }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("forwards the accessibility permission preference from a trusted renderer", () => { - let includeAccessibility: boolean | undefined; - const webContents = { id: 7 }; - const layer = Layer.mergeAll( - Layer.succeed( - ElectronWindow.ElectronWindow, - ElectronWindow.ElectronWindow.of({ - main: Effect.succeedSome({ webContents }), - } as ElectronWindow.ElectronWindow["Service"]), - ), - Layer.succeed( - DesktopSnapShot.DesktopSnapShot, - DesktopSnapShot.DesktopSnapShot.of({ - requestPermissions: (include: boolean) => - Effect.sync(() => { - includeAccessibility = include; - }), - } as unknown as DesktopSnapShot.DesktopSnapShot["Service"]), - ), - ); - - return Effect.gen(function* () { - yield* requestSnapShotPermissions.handler(false, { sender: webContents }); - assert.isFalse(includeAccessibility); - }).pipe(Effect.provide(layer)); - }); - - it.effect("rejects an untrusted renderer at the IPC boundary", () => - Effect.gen(function* () { - const exit = yield* Effect.exit( - requestSnapShotPermissions.handler(false, { sender: { id: 8 } }), - ); - assert(Exit.isFailure(exit)); - const failure = Cause.findErrorOption(exit.cause); - assert(Option.isSome(failure)); - const error = failure.value; - - assert.equal((error as { readonly _tag: string })._tag, "SnapShotIpcUnauthorizedSenderError"); - assert.equal((error as Error).message, "Snapshot request was rejected."); - }).pipe( - Effect.provideService( - ElectronWindow.ElectronWindow, - ElectronWindow.ElectronWindow.of({ - main: Effect.succeedSome({ webContents: { id: 7 } }), - } as ElectronWindow.ElectronWindow["Service"]), - ), - Effect.provideService(DesktopSnapShot.DesktopSnapShot, null as never), - ), - ); - it.effect("allows capture setup only from the trusted main renderer", () => { const actions: string[] = []; return Effect.gen(function* () { @@ -287,56 +93,6 @@ describe("window capture IPC", () => { ), ); }); - - it.effect("checks shortcut availability for a trusted renderer", () => { - const layer = Layer.mergeAll( - Layer.succeed( - ElectronWindow.ElectronWindow, - ElectronWindow.ElectronWindow.of({ - main: Effect.succeedSome({ webContents: { id: 7 } }), - } as ElectronWindow.ElectronWindow["Service"]), - ), - Layer.succeed( - DesktopSnapShot.DesktopSnapShot, - DesktopSnapShot.DesktopSnapShot.of({ - checkShortcut: () => Effect.succeed({ available: true, message: null }), - } as unknown as DesktopSnapShot.DesktopSnapShot["Service"]), - ), - ); - - return Effect.gen(function* () { - const result = yield* checkSnapShotShortcut.handler( - { kind: "both-shift-keys" }, - { sender: { id: 7 } }, - ); - assert.deepEqual(result, { available: true, message: null }); - }).pipe(Effect.provide(layer)); - }); - it.effect("suppresses the active shortcut for a trusted renderer", () => { - let suppressed = false; - const layer = Layer.mergeAll( - Layer.succeed( - ElectronWindow.ElectronWindow, - ElectronWindow.ElectronWindow.of({ - main: Effect.succeedSome({ webContents: { id: 7 } }), - } as ElectronWindow.ElectronWindow["Service"]), - ), - Layer.succeed( - DesktopSnapShot.DesktopSnapShot, - DesktopSnapShot.DesktopSnapShot.of({ - setShortcutSuppressed: (next: boolean) => - Effect.sync(() => { - suppressed = next; - }), - } as unknown as DesktopSnapShot.DesktopSnapShot["Service"]), - ), - ); - - return Effect.gen(function* () { - yield* setSnapShotShortcutSuppressed.handler(true, { sender: { id: 7 } }); - assert.isTrue(suppressed); - }).pipe(Effect.provide(layer)); - }); }); it("normalizes Wayland attachment coordinates without trusting Electron's screen origin", () => { diff --git a/apps/desktop/src/ipc/methods/snapShot.ts b/apps/desktop/src/ipc/methods/snapShot.ts index 4b5e189c2089..dff64081b70c 100644 --- a/apps/desktop/src/ipc/methods/snapShot.ts +++ b/apps/desktop/src/ipc/methods/snapShot.ts @@ -45,7 +45,7 @@ const ensureTrustedSnapShotSender = Effect.fn("desktop.ipc.snapShot.ensureTruste }, ); -export function snapShotScreenFrame( +function snapShotScreenFrame( viewportFrame: DesktopSnapShotAnimationDestination["viewportFrame"], contentBounds: Electron.Rectangle, zoomFactor: number, diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts index 0ed188ca61b5..1b40db955fe6 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts @@ -34,59 +34,6 @@ function layerHttpClient( } describe("SSH environment IPC", () => { - it.effect("fetches and decodes the remote environment descriptor", () => { - const requestUrls: string[] = []; - const layer = layerHttpClient((request) => - Effect.sync(() => { - requestUrls.push(request.url); - return jsonResponse(request, { - environmentId: "remote-env", - label: "Remote Devbox", - platform: { os: "linux", arch: "x64" }, - serverVersion: "1.2.3", - capabilities: { repositoryIdentity: true }, - }); - }), - ); - - return Effect.gen(function* () { - const descriptor = yield* fetchSshEnvironmentDescriptor.handler({ - httpBaseUrl: "http://127.0.0.1:41773/", - }); - - assert.deepEqual(descriptor, { - environmentId: "remote-env", - label: "Remote Devbox", - platform: { os: "linux", arch: "x64" }, - serverVersion: "1.2.3", - capabilities: { repositoryIdentity: true }, - }); - assert.deepEqual(requestUrls, ["http://127.0.0.1:41773/.well-known/t3/environment"]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("wraps schema decode failures in a typed request error", () => { - const layer = layerHttpClient((request) => - Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })), - ); - - return Effect.gen(function* () { - const exit = yield* Effect.exit( - fetchSshEnvironmentDescriptor.handler({ - httpBaseUrl: "http://127.0.0.1:41773/", - }), - ); - assert(Exit.isFailure(exit)); - const failure = Cause.findErrorOption(exit.cause); - assert(Option.isSome(failure)); - const error = failure.value; - - assert.instanceOf(error, DesktopSshEnvironmentRequestError); - assert.equal(error.operation, "fetch-environment-descriptor"); - assert.equal(error.cause instanceof SshHttpBridgeError, false); - }).pipe(Effect.provide(layer)); - }); - it.effect("rejects non-loopback HTTP endpoints before issuing a request", () => { let requestCount = 0; const layer = layerHttpClient((request) => diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index d21f3cd60505..b5fe707f4cae 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -8,8 +8,6 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { vi } from "vite-plus/test"; -import type * as Electron from "electron"; - const { focusedWebContents, ownerWindow } = vi.hoisted(() => ({ focusedWebContents: vi.fn(), ownerWindow: vi.fn(), @@ -22,14 +20,9 @@ vi.mock("electron", () => ({ import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; -import * as ElectronDialog from "../../electron/ElectronDialog.ts"; -import * as ElectronWindow from "../../electron/ElectronWindow.ts"; -import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import { getLocalEnvironmentBootstraps, - getWindowFullscreenState, - pasteAsText, - pickProjectFavicon, + getRelayTelemetryEnabled, probeRemoteEditors, } from "./window.ts"; @@ -86,23 +79,6 @@ const bootstrapsLayer = (instances: ReadonlyArray { - it.effect("publishes the concrete running distro without replacing the stable instance id", () => - Effect.gen(function* () { - const result = yield* getLocalEnvironmentBootstraps.handler(); - - assert.deepEqual(result, [ - { - id: "wsl:default", - label: "WSL (Ubuntu)", - runningDistro: "Ubuntu", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - bootstrapToken: "bootstrap-token", - }, - ]); - }).pipe(Effect.provide(bootstrapsLayer([defaultWslInstance]))), - ); - it.effect("hands out the current window's token to a backend launched with the secret", () => Effect.gen(function* () { const result = yield* getLocalEnvironmentBootstraps.handler(); @@ -197,119 +173,21 @@ describe("getLocalEnvironmentBootstraps", () => { }); }); -describe("getWindowFullscreenState", () => { - it.effect("reads the current native window state", () => { - const window = { isFullScreen: () => true } as Electron.BrowserWindow; - - return Effect.gen(function* () { - assert.isTrue(yield* getWindowFullscreenState.handler()); - }).pipe( - Effect.provide( - Layer.mock(ElectronWindow.ElectronWindow)({ - currentMainOrFirst: Effect.succeedSome(window), - }), - ), - ); - }); -}); - -describe("pasteAsText", () => { - it.effect( - "pastes into the focused guest only after the main renderer acknowledges the menu action", - () => { - const paste = vi.fn(); - const mainPaste = vi.fn(); - const window = { - webContents: { id: 42, paste: mainPaste }, - isDestroyed: () => false, - } as unknown as Electron.BrowserWindow; - focusedWebContents.mockReturnValue({ paste, isDestroyed: () => false }); - ownerWindow.mockReturnValue(window); - - return Effect.gen(function* () { - yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); - assert.equal(paste.mock.calls.length, 1); - assert.equal(mainPaste.mock.calls.length, 0); - - yield* pasteAsText.handler(undefined, { sender: { id: 99 } }); - assert.equal(paste.mock.calls.length, 1); - ownerWindow.mockReturnValue({}); // A focused PiP/other BrowserWindow. - yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); - assert.equal(paste.mock.calls.length, 1); - ownerWindow.mockReturnValue(null); // Detached contents. - yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); - assert.equal(paste.mock.calls.length, 1); - ownerWindow.mockReturnValue(window); - focusedWebContents.mockReturnValue({ paste, isDestroyed: () => true }); - yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); - assert.equal(paste.mock.calls.length, 1); - focusedWebContents.mockReturnValue(null); - yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); - assert.equal(paste.mock.calls.length, 1); - }).pipe( - Effect.provide( - Layer.mock(ElectronWindow.ElectronWindow)({ - main: Effect.succeedSome(window), - }), - ), - ); - }, - ); -}); - -describe("pickProjectFavicon", () => { - const pickerLayer = ( - pickFiles: () => Effect.Effect>, - settings?: DesktopAppSettings.DesktopSettings, - ) => - Layer.mergeAll( - Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), - Layer.mock(ElectronWindow.ElectronWindow)({ - focusedMainOrFirst: Effect.succeedNone, - }), - DesktopAppSettings.layerTest(settings), - ); - - it.effect("opens a single-image picker from the project directory", () => +describe("getRelayTelemetryEnabled", () => { + it.effect("reads the desktop host opt-out before returning the renderer policy", () => Effect.gen(function* () { - const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); - const result = yield* pickProjectFavicon - .handler("/project") - .pipe(Effect.provide(pickerLayer(pickFiles))); - - assert.strictEqual(result, "/pictures/icon.png"); - assert.deepEqual(pickFiles.mock.calls, [ - [ - { - owner: Option.none(), - defaultPath: Option.some("/project"), - multiple: false, - filters: [ - { - name: "Images", - extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp", "icns"], - }, - ], - }, - ], - ]); - }), - ); - - it.effect("does not open a picker while the local environment is off", () => - Effect.gen(function* () { - const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); - const result = yield* pickProjectFavicon.handler("/project").pipe( - Effect.provide( - pickerLayer(pickFiles, { - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - localEnvironmentEnabled: false, + assert.isFalse( + yield* getRelayTelemetryEnabled.handler().pipe( + Effect.provideService(HostProcess.Environment, { + T3CODE_TELEMETRY_ENABLED: "false", }), ), ); - - assert.strictEqual(result, null); - assert.strictEqual(pickFiles.mock.calls.length, 0); + assert.isTrue( + yield* getRelayTelemetryEnabled + .handler() + .pipe(Effect.provideService(HostProcess.Environment, {})), + ); }), ); }); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 6a2bfc7f1773..9066cf6f1a67 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -16,6 +16,7 @@ import { import { PROJECT_FAVICON_EXTENSIONS } from "@t3tools/shared/projectFavicon"; import { resolveEditorCommand } from "@t3tools/shared/editor"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { isRelayTracingEnabled } from "@t3tools/shared/otelEnvironment"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; @@ -71,6 +72,14 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getRelayTelemetryEnabled = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_RELAY_TELEMETRY_ENABLED_CHANNEL, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.getRelayTelemetryEnabled")(function* () { + return isRelayTracingEnabled(yield* HostProcess.Environment); + }), +}); + export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_SYSTEM_LOCALE_CHANNEL, result: Schema.String, @@ -352,6 +361,24 @@ export const pasteAsText = DesktopIpc.makeIpcMethod({ }), }); +export const revealWindow = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.REVEAL_WINDOW_CHANNEL, + payload: Schema.Undefined, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.revealWindow")(function* (_input, event) { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.main; + if ( + event === undefined || + Option.isNone(window) || + window.value.webContents.id !== event.sender.id + ) { + return; + } + yield* electronWindow.reveal(window.value); + }), +}); + const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; export const pickThemeFiles = DesktopIpc.makeIpcMethod({ diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 644597eaf451..af42c7264748 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -17,7 +17,7 @@ import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts" import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; -import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; +import { setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; const decodeWslState = Schema.decodeUnknownEffect(DesktopWslStateSchema); @@ -84,73 +84,6 @@ const layerUnusedLifecycleRuntime = Layer.mergeAll( ); describe("WSL IPC", () => { - it.effect("does not probe WSL when local execution is disabled", () => - Effect.gen(function* () { - const wsl = yield* DesktopWslEnvironment.DesktopWslEnvironment; - const state = yield* getWslState.handler(undefined).pipe( - Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, { - ...wsl, - isAvailable: Effect.die("must not probe WSL"), - listDistros: Effect.die("must not enumerate distros"), - }), - Effect.flatMap(decodeWslState), - ); - assert.deepEqual(state, { - enabled: true, - distro: "Ubuntu", - available: false, - wslOnly: true, - distros: [], - preflightError: null, - }); - }).pipe( - Effect.provide( - Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - localEnvironmentEnabled: false, - wslBackendEnabled: true, - wslDistro: "Ubuntu", - wslOnly: true, - }), - DesktopWslEnvironment.layerTest(), - layerWslBackend(), - ), - ), - ), - ); - - it.effect("stages dual-backend preferences before enabling without relaunching", () => { - const relaunchReasons: Array = []; - const layer = Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: false, - wslOnly: true, - }), - DesktopWslEnvironment.layerTest({ isAvailable: true }), - layerWslBackend(), - layerLifecycle(relaunchReasons), - layerUnusedLifecycleRuntime, - ); - - return Effect.gen(function* () { - yield* invokeSetWslOnly(false); - yield* invokeSetWslDistro("Debian"); - const state = yield* invokeSetWslBackendEnabled(true); - - assert.deepEqual(state, { - enabled: true, - distro: "Debian", - available: true, - wslOnly: false, - distros: [], - preflightError: null, - }); - assert.deepEqual(relaunchReasons, []); - }).pipe(Effect.provide(layer)); - }); - it.effect("stages WSL-only preferences and relaunches only after enabling", () => { const relaunchReasons: Array = []; const layer = Layer.mergeAll( @@ -187,70 +120,6 @@ describe("WSL IPC", () => { }).pipe(Effect.provide(layer)); }); - it.effect("relaunches when enabling the WSL backend while wsl-only is already persisted", () => { - const relaunchReasons: Array = []; - let reconcileCount = 0; - const layer = Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: false, - wslOnly: true, - }), - DesktopWslEnvironment.layerTest({ isAvailable: true }), - layerWslBackend({ - onReconcile: Effect.sync(() => { - reconcileCount += 1; - }), - }), - layerLifecycle(relaunchReasons), - layerUnusedLifecycleRuntime, - ); - - return Effect.gen(function* () { - const state = yield* invokeSetWslBackendEnabled(true); - - assert.deepEqual(state, { - enabled: true, - distro: null, - available: true, - wslOnly: true, - distros: [], - preflightError: null, - }); - assert.equal(reconcileCount, 0); - assert.deepEqual(relaunchReasons, ["wslBackendEnabled=true"]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("reconciles in dual-backend mode without relaunching", () => { - const relaunchReasons: Array = []; - let reconcileCount = 0; - const layer = Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: false, - wslOnly: false, - }), - DesktopWslEnvironment.layerTest({ isAvailable: true }), - layerWslBackend({ - onReconcile: Effect.sync(() => { - reconcileCount += 1; - }), - }), - layerLifecycle(relaunchReasons), - layerUnusedLifecycleRuntime, - ); - - return Effect.gen(function* () { - const state = yield* invokeSetWslBackendEnabled(true); - - assert.equal(state.enabled, true); - assert.equal(state.wslOnly, false); - assert.equal(reconcileCount, 1); - assert.deepEqual(relaunchReasons, []); - }).pipe(Effect.provide(layer)); - }); - it.effect("clears wsl-only before relaunching when disabling a WSL-only backend", () => { const relaunchReasons: Array = []; let reconcileCount = 0; @@ -289,33 +158,4 @@ describe("WSL IPC", () => { assert.deepEqual(relaunchReasons, ["wslBackendEnabled=false"]); }).pipe(Effect.provide(layer)); }); - - it.effect("clears dual-backend WSL without relaunching", () => { - const relaunchReasons: Array = []; - let reconcileCount = 0; - const layer = Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: true, - wslOnly: false, - }), - DesktopWslEnvironment.layerTest({ isAvailable: true }), - layerWslBackend({ - onReconcile: Effect.sync(() => { - reconcileCount += 1; - }), - }), - layerLifecycle(relaunchReasons), - layerUnusedLifecycleRuntime, - ); - - return Effect.gen(function* () { - const state = yield* invokeSetWslBackendEnabled(false); - - assert.equal(state.enabled, false); - assert.equal(state.wslOnly, false); - assert.equal(reconcileCount, 1); - assert.deepEqual(relaunchReasons, []); - }).pipe(Effect.provide(layer)); - }); }); diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index 157175e53332..aaf0c11bcce9 100644 --- a/apps/desktop/src/linuxSecretStorage.test.ts +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -1,37 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { - normalizeLinuxPasswordStorePreference, - resolveLinuxPasswordStoreSwitch, -} from "./linuxSecretStorage.ts"; +import { resolveLinuxPasswordStoreSwitch } from "./linuxSecretStorage.ts"; const autoSwitch = (env: NodeJS.ProcessEnv) => resolveLinuxPasswordStoreSwitch({ preference: "auto", env }); describe("linuxSecretStorage", () => { - it("preserves explicit supported password-store preferences", () => { - expect(normalizeLinuxPasswordStorePreference("gnome-libsecret")).toBe("gnome-libsecret"); - expect(normalizeLinuxPasswordStorePreference("kwallet")).toBe("kwallet"); - expect(normalizeLinuxPasswordStorePreference("kwallet5")).toBe("kwallet5"); - expect(normalizeLinuxPasswordStorePreference("kwallet6")).toBe("kwallet6"); - }); - - it("falls back to auto for missing or unsupported preferences", () => { - expect(normalizeLinuxPasswordStorePreference(undefined)).toBe("auto"); - expect(normalizeLinuxPasswordStorePreference("basic")).toBe("auto"); - }); - - it("uses explicit preferences instead of the auto heuristic", () => { - for (const preference of ["gnome-libsecret", "kwallet", "kwallet5", "kwallet6"] as const) { - expect( - resolveLinuxPasswordStoreSwitch({ preference, env: { XDG_CURRENT_DESKTOP: "niri" } }), - ).toBe(preference); - expect( - resolveLinuxPasswordStoreSwitch({ preference, env: { XDG_CURRENT_DESKTOP: "KDE" } }), - ).toBe(preference); - } - }); - it("leaves canonical KDE sessions to Electron's own wallet selection", () => { expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE" })).toBeNull(); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE", KDE_SESSION_VERSION: "6" })).toBeNull(); @@ -39,13 +13,6 @@ describe("linuxSecretStorage", () => { expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE:plasma" })).toBeNull(); }); - it("does not force a password-store for desktops Electron already recognizes", () => { - expect(autoSwitch({ XDG_CURRENT_DESKTOP: "GNOME" })).toBeNull(); - for (const desktop of ["Deepin", "Pantheon", "UKUI", "Unity", "X-Cinnamon", "XFCE"]) { - expect(autoSwitch({ XDG_CURRENT_DESKTOP: desktop })).toBeNull(); - } - }); - it("recognizes a known desktop later in a colon-separated XDG_CURRENT_DESKTOP list", () => { expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri:GNOME" })).toBeNull(); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "ubuntu:GNOME" })).toBeNull(); diff --git a/apps/desktop/src/permissions/MacPermissionHelper.test.ts b/apps/desktop/src/permissions/MacPermissionHelper.test.ts index e1160d9bbe38..0704f52254ef 100644 --- a/apps/desktop/src/permissions/MacPermissionHelper.test.ts +++ b/apps/desktop/src/permissions/MacPermissionHelper.test.ts @@ -1,8 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import * as Electron from "electron"; -import { MacPermissionHelper, macAppBundlePath } from "./MacPermissionHelper.ts"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { MacPermissionHelper } from "./MacPermissionHelper.ts"; import type { SettingsWindow } from "./MacSettingsWindow.ts"; -import { MAC_PERMISSION_HELPER_CHANNEL } from "../ipc/channels.ts"; const mocks = vi.hoisted(() => ({ granted: false, @@ -110,193 +108,6 @@ afterEach(() => { const iconPaths = ["/bundle/prod-resources/icon.png"]; const open = () => helper.show("accessibility", "/bundle/mac-permission-preload.cjs", null, iconPaths); -function send(action: string, trusted = true) { - const window = windows.at(-1)!; - Electron.ipcMain.emit( - MAC_PERMISSION_HELPER_CHANNEL, - { - sender: trusted ? window.webContents : {}, - senderFrame: window.webContents.mainFrame, - }, - action, - ); -} - -describe("macAppBundlePath", () => { - it("resolves bundles with spaces and refuses non-bundle executables", () => { - expect(macAppBundlePath("/Applications/T3 Code.app/Contents/MacOS/T3 Code")).toBe( - "/Applications/T3 Code.app", - ); - expect(macAppBundlePath("/usr/local/bin/electron")).toBeUndefined(); - expect(macAppBundlePath("/Applications/T3 Code.app/other/MacOS/T3 Code")).toBeUndefined(); - }); -}); -it("drags the running app bundle only for the helper's own renderer", async () => { - await open(); - send("drag", false); - expect(mocks.startDrag).not.toHaveBeenCalled(); - send("drag"); - expect(mocks.createFromPath).toHaveBeenCalledWith("/bundle/prod-resources/icon.png"); - expect(mocks.startDrag).toHaveBeenCalledWith({ - file: "/Applications/T3 Code (Nightly).app", - icon: mocks.createFromPath.mock.results[0]!.value.resize(), - }); - send("finder"); - expect(mocks.showItemInFolder).toHaveBeenCalledWith("/Applications/T3 Code (Nightly).app"); -}); -it("rechecks permissions and releases resources when granted", async () => { - await open(); - mocks.granted = true; - await vi.advanceTimersByTimeAsync(1000); - expect(windows[0]!.destroyed).toBe(true); - expect(Electron.ipcMain.listenerCount(MAC_PERMISSION_HELPER_CHANNEL)).toBe(0); - expect(vi.getTimerCount()).toBe(0); -}); -it("keeps only one helper and cleans up on dismissal", async () => { - await open(); - await helper.show("screen-recording", "/preload.cjs", null, iconPaths); - expect(windows[0]!.destroyed).toBe(true); - expect(Electron.ipcMain.listenerCount(MAC_PERMISSION_HELPER_CHANNEL)).toBe(1); - send("close"); - expect(windows[1]!.destroyed).toBe(true); - expect(vi.getTimerCount()).toBe(0); -}); -it("does not open for a permission already granted", async () => { - mocks.granted = true; - await open(); - expect(windows).toHaveLength(0); -}); -it("does not show a helper with a missing packaged icon", async () => { - mocks.createFromPath.mockReturnValueOnce({ isEmpty: () => true }); - await expect(open()).rejects.toThrow("packaged T3 Code icon is missing"); - expect(windows).toHaveLength(0); -}); -it("cleans up when the helper page fails to load", async () => { - mocks.loadURL.mockRejectedValueOnce(new Error("load failed")); - await expect(open()).rejects.toThrow("load failed"); - expect(Electron.ipcMain.listenerCount(MAC_PERMISSION_HELPER_CHANNEL)).toBe(0); - expect(vi.getTimerCount()).toBe(0); -}); - -it("offers the Finder fallback when native dragging fails", async () => { - await open(); - mocks.startDrag.mockImplementationOnce(() => { - throw new Error("drag failed"); - }); - send("drag"); - expect(mocks.showItemInFolder).toHaveBeenCalledWith("/Applications/T3 Code (Nightly).app"); - expect(windows[0]!.destroyed).toBe(false); -}); - -it("returns focus to onboarding when the permission is granted", async () => { - const owner = new Electron.BrowserWindow({}); - await helper.show("screen-recording", "/preload.cjs", owner, iconPaths); - mocks.granted = true; - await vi.advanceTimersByTimeAsync(1000); - expect(owner.show).toHaveBeenCalledOnce(); - expect(owner.focus).toHaveBeenCalledOnce(); - expect(windows[1]!.destroyed).toBe(true); - expect(owner.listenerCount("closed")).toBe(0); -}); -it("closes the helper and stops checking when onboarding's window closes", async () => { - const owner = new Electron.BrowserWindow({}); - await helper.show("accessibility", "/preload.cjs", owner, iconPaths); - owner.destroy(); - expect(windows[1]!.destroyed).toBe(true); - expect(Electron.ipcMain.listenerCount(MAC_PERMISSION_HELPER_CHANNEL)).toBe(0); - expect(vi.getTimerCount()).toBe(0); -}); - -it("uses the packaged PNG when an earlier resource candidate is absent", async () => { - mocks.createFromPath.mockReturnValueOnce({ isEmpty: () => true }); - await helper.show("accessibility", "/preload.cjs", null, ["/missing/icon.png", ...iconPaths]); - send("drag"); - expect(mocks.startDrag).toHaveBeenCalled(); - expect(mocks.createFromPath).toHaveBeenLastCalledWith(iconPaths[0]); -}); - -it("docks inside Settings and hides when it is covered or closed", async () => { - await open(); - const window = windows[0]!; - const settings = { x: 367, y: 100, width: 723, height: 719, frontmost: true }; - mocks.settingsChanged!(settings); - expect(window.setBounds).toHaveBeenLastCalledWith( - { x: 599, y: 663, width: 475, height: 140 }, - false, - ); - expect(window.showInactive).toHaveBeenCalledOnce(); - mocks.settingsChanged!({ ...settings, x: -800, y: 200 }); - expect(window.setBounds).toHaveBeenLastCalledWith( - { x: -568, y: 763, width: 475, height: 140 }, - false, - ); - mocks.settingsChanged!({ ...settings, frontmost: false }); - expect(window.hide).toHaveBeenCalledOnce(); - mocks.settingsChanged!(null); - expect(window.destroyed).toBe(true); - helper.close(); - expect(mocks.stopTracking).toHaveBeenCalledOnce(); -}); - -it("returns to onboarding when the Settings window disappears", async () => { - const owner = new Electron.BrowserWindow({}); - owner.hide(); - await helper.show("accessibility", "/preload.cjs", owner, iconPaths); - mocks.settingsChanged!({ x: 100, y: 100, width: 723, height: 719, frontmost: true }); - mocks.settingsChanged!(null); - expect(owner.show).toHaveBeenCalledOnce(); - expect(owner.focus).toHaveBeenCalledOnce(); -}); -it("hides on tracking failure and resumes on a valid update", async () => { - await open(); - const state = { x: 100, y: 100, width: 723, height: 719, frontmost: true }; - mocks.settingsChanged!(state); - mocks.trackingFailed!(); - expect(windows[0]!.destroyed).toBe(false); - expect(windows[0]!.hide).toHaveBeenCalledOnce(); - mocks.settingsChanged!(state); - expect(windows[0]!.showInactive).toHaveBeenCalledTimes(2); -}); - -it("waits for an asynchronous Full Disk Access check and returns to the owner", async () => { - const owner = new Electron.BrowserWindow(); - const probe = vi.fn<() => Promise>().mockResolvedValue(false); - await helper.show( - "full-disk-access", - "/bundle/mac-permission-preload.cjs", - owner, - iconPaths, - probe, - ); - const pending = Promise.withResolvers(); - probe.mockReturnValue(pending.promise); - await vi.advanceTimersByTimeAsync(3000); - expect(probe).toHaveBeenCalledTimes(2); - expect(windows[1]!.destroyed).toBe(false); - pending.resolve(true); - await pending.promise; - expect(windows[1]!.destroyed).toBe(true); - expect(owner.focus).toHaveBeenCalledOnce(); - expect(vi.getTimerCount()).toBe(0); -}); - -it("keeps the helper open after a failed access check and retries", async () => { - const probe = vi.fn<() => Promise>().mockResolvedValue(false); - await helper.show( - "full-disk-access", - "/bundle/mac-permission-preload.cjs", - null, - iconPaths, - probe, - ); - probe.mockRejectedValueOnce(new Error("temporarily unavailable")); - await vi.advanceTimersByTimeAsync(1000); - expect(windows[0]!.destroyed).toBe(false); - probe.mockResolvedValue(true); - await vi.advanceTimersByTimeAsync(1000); - expect(windows[0]!.destroyed).toBe(true); -}); - it("does not reopen a superseded helper when its initial probe completes", async () => { const pending = Promise.withResolvers(); const first = helper.show( diff --git a/apps/desktop/src/permissions/MacPermissionHelper.ts b/apps/desktop/src/permissions/MacPermissionHelper.ts index dfc378cfd1c4..4a8e38961f20 100644 --- a/apps/desktop/src/permissions/MacPermissionHelper.ts +++ b/apps/desktop/src/permissions/MacPermissionHelper.ts @@ -18,7 +18,7 @@ const permissionGranted = (permission: MacPermission) => { return false; }; -export function macAppBundlePath(executable: string): string | undefined { +function macAppBundlePath(executable: string): string | undefined { return /^(.+\.app)\/Contents\/MacOS\/[^/]+$/.exec(executable)?.[1]; } diff --git a/apps/desktop/src/permissions/MacSettingsWindow.test.ts b/apps/desktop/src/permissions/MacSettingsWindow.test.ts index 3071b00c3264..d07b4f5f9a76 100644 --- a/apps/desktop/src/permissions/MacSettingsWindow.test.ts +++ b/apps/desktop/src/permissions/MacSettingsWindow.test.ts @@ -1,6 +1,6 @@ import * as NodeEvents from "node:events"; import { beforeEach, expect, it, vi } from "vite-plus/test"; -import { settingsHelperBounds, watchMacSettingsWindow } from "./MacSettingsWindow.ts"; +import { watchMacSettingsWindow } from "./MacSettingsWindow.ts"; const mocks = vi.hoisted(() => ({ spawn: vi.fn() })); vi.mock("node:child_process", () => ({ spawn: mocks.spawn })); @@ -15,17 +15,6 @@ beforeEach(() => { }); mocks.spawn.mockReturnValue(child); }); -it("places the helper inside small and large Settings windows across displays", () => { - for (const x of [-900, 20]) { - for (const width of [668, 1000]) { - const state = { x, y: 30, width, height: 700, frontmost: true }; - const helper = settingsHelperBounds(state); - expect(helper.x).toBeGreaterThanOrEqual(x + 216); - expect(helper.x + helper.width).toBeLessThanOrEqual(x + width - 16); - expect(helper.y + helper.height).toBe(714); - } - } -}); it("decodes partial updates and stops the one watcher process on disposal", () => { const changed = vi.fn(); const unavailable = vi.fn(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2e046b7f238c..bffa02d9ca43 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -82,6 +82,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getRelayTelemetryEnabled: () => + ipcRenderer.sendSync(IpcChannels.GET_RELAY_TELEMETRY_ENABLED_CHANNEL) === true, getPathForFile: (file: File) => webUtils.getPathForFile(file), getClientPlatform: () => clientPlatform, setNotificationBadge: (badge) => @@ -96,6 +98,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler); return () => ipcRenderer.removeListener(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler); }, + revealWindow: () => ipcRenderer.invoke(IpcChannels.REVEAL_WINDOW_CHANNEL), getSystemLocale: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; @@ -250,6 +253,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, getWindowFullscreenState: () => ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, + getWindowZoomFactor: () => webFrame.getZoomFactor(), onWindowFullscreenStateChange: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, fullscreen: unknown) => { if (typeof fullscreen !== "boolean") return; diff --git a/apps/desktop/src/preview/AnnotationIds.test.ts b/apps/desktop/src/preview/AnnotationIds.test.ts index 86eeccc92ab6..1104c422f3ee 100644 --- a/apps/desktop/src/preview/AnnotationIds.test.ts +++ b/apps/desktop/src/preview/AnnotationIds.test.ts @@ -16,10 +16,4 @@ describe("createAnnotationIdSource", () => { expect(secondAnnotation).not.toBe(firstAnnotation); }); - - it("never repeats an id within one page load", () => { - const nextId = createAnnotationIdSource(); - const ids = Array.from({ length: 1_000 }, () => nextId("annotation")); - expect(new Set(ids).size).toBe(ids.length); - }); }); diff --git a/apps/desktop/src/preview/AnnotationKeyboard.test.ts b/apps/desktop/src/preview/AnnotationKeyboard.test.ts index 4124e548bec1..11fb9100e398 100644 --- a/apps/desktop/src/preview/AnnotationKeyboard.test.ts +++ b/apps/desktop/src/preview/AnnotationKeyboard.test.ts @@ -14,24 +14,9 @@ const keyboardEvent = ( }); describe("resolveAnnotationSubmission", () => { - it("attaches on Enter and sends on Cmd/Ctrl+Enter", () => { - expect(resolveAnnotationSubmission(keyboardEvent(), true)).toBe("attach"); - expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }), true)).toBe("send"); - expect(resolveAnnotationSubmission(keyboardEvent({ ctrlKey: true }), true)).toBe("send"); - }); - it("leaves Shift+Enter and composition events available for editing", () => { expect(resolveAnnotationSubmission(keyboardEvent({ shiftKey: true }), true)).toBeNull(); expect(resolveAnnotationSubmission(keyboardEvent({ isComposing: true }), true)).toBeNull(); expect(resolveAnnotationSubmission(keyboardEvent({ key: " " }), true)).toBeNull(); }); - - it("keeps attach available while the send shortcut follows permission changes", () => { - const send = keyboardEvent({ ctrlKey: true }); - expect(resolveAnnotationSubmission(send, false)).toBeNull(); - expect(resolveAnnotationSubmission(keyboardEvent(), false)).toBe("attach"); - expect(resolveAnnotationSubmission(send, true)).toBe("send"); - expect(resolveAnnotationSubmission(send, false)).toBeNull(); - expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }), false)).toBeNull(); - }); }); diff --git a/apps/desktop/src/preview/AnnotationStyles.generated.ts b/apps/desktop/src/preview/AnnotationStyles.generated.ts index 53d83d584b32..d02abc8ac612 100644 --- a/apps/desktop/src/preview/AnnotationStyles.generated.ts +++ b/apps/desktop/src/preview/AnnotationStyles.generated.ts @@ -1,3 +1,3 @@ // Generated by scripts/build-preview-annotation-css.mjs. Do not edit. export const previewAnnotationStyles = - '/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', \'Noto Sans\', Arial, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring:where(:not(iframe)) {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.visible {\n visibility: visible;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: 0px;\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: 0px;\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: var(--spacing);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: 0px;\n}\n.p-1 {\n padding: var(--spacing);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: 0px;\n}\n.px-1 {\n padding-inline: var(--spacing);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: var(--spacing);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground::placeholder {\n color: var(--t3-muted-foreground);\n}\n@media (hover: hover) {\n .hover\\:bg-accent:hover {\n background-color: var(--t3-accent);\n }\n .hover\\:bg-primary\\/90:hover {\n background-color: var(--t3-primary);\n }\n @supports (color: color-mix(in lab, red, red)) {\n .hover\\:bg-primary\\/90:hover {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n .hover\\:text-accent-foreground:hover {\n color: var(--t3-accent-foreground);\n }\n}\n.focus\\:border-b-primary:focus {\n border-bottom-color: var(--t3-primary);\n}\n.focus\\:ring-0:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.focus\\:outline-none:focus {\n --tw-outline-style: none;\n outline-style: none;\n}\n.disabled\\:pointer-events-none:disabled {\n pointer-events: none;\n}\n.disabled\\:opacity-60:disabled {\n opacity: 60%;\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: -2px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; + '/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', \'Noto Sans\', Arial, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring:where(:not(iframe)) {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.visible {\n visibility: visible;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: 0px;\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: 0px;\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: var(--spacing);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: 0px;\n}\n.p-1 {\n padding: var(--spacing);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: 0px;\n}\n.px-1 {\n padding-inline: var(--spacing);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: var(--spacing);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground::placeholder {\n color: var(--t3-muted-foreground);\n}\n@media (hover: hover) {\n .hover\\:bg-accent:hover {\n background-color: var(--t3-accent);\n }\n .hover\\:bg-primary\\/90:hover {\n background-color: var(--t3-primary);\n }\n @supports (color: color-mix(in lab, red, red)) {\n .hover\\:bg-primary\\/90:hover {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n .hover\\:text-accent-foreground:hover {\n color: var(--t3-accent-foreground);\n }\n}\n.focus\\:border-b-primary:focus {\n border-bottom-color: var(--t3-primary);\n}\n.focus\\:ring-0:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.focus\\:outline-none:focus {\n --tw-outline-style: none;\n outline-style: none;\n}\n.disabled\\:pointer-events-none:disabled {\n pointer-events: none;\n}\n.disabled\\:opacity-60:disabled {\n opacity: 60%;\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: -2px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n}\n'; diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index 22e384834789..4815da11cba1 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -3,9 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; import * as BrowserSession from "../BrowserSession.ts"; import * as BrowserImport from "./BrowserImport.ts"; @@ -120,31 +118,6 @@ describe("BrowserImport.importCookies", () => { }); describe("BrowserImport.writeCookies", () => { - it.effect("counts a rejected cookie and its domain as skipped", () => - Effect.gen(function* () { - let flushes = 0; - const result = yield* BrowserImport.writeCookies( - { - cookies: { - set: () => Promise.reject(new Error("fixture rejection")), - flushStore: () => { - flushes += 1; - return Promise.resolve(); - }, - }, - }, - { cookies: [cookie], undecryptable: 0, undecryptableHosts: [] }, - ); - - assert.deepEqual(result, { - imported: 0, - skipped: 1, - skippedDomains: ["rejected.example"], - }); - assert.equal(flushes, 0); - }), - ); - it.effect("flushes the store after writing, and reports success if the flush fails", () => Effect.gen(function* () { const events: Array = []; @@ -168,28 +141,4 @@ describe("BrowserImport.writeCookies", () => { assert.deepEqual(result, { imported: 2, skipped: 0, skippedDomains: [] }); }), ); - - it.effect("propagates interruption while writing a cookie", () => - Effect.gen(function* () { - const write = BrowserImport.writeCookies( - { - cookies: { - set: () => new Promise(() => {}), - flushStore: () => Promise.resolve(), - }, - }, - { cookies: [cookie], undecryptable: 0, undecryptableHosts: [] }, - ); - - const interrupted = yield* Ref.make(false); - const fiber = yield* write.pipe( - Effect.onInterrupt(() => Ref.set(interrupted, true)), - Effect.forkChild, - ); - yield* Effect.yieldNow; - yield* Fiber.interrupt(fiber); - - assert.isTrue(yield* Ref.get(interrupted)); - }), - ); }); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index eace39003e39..91be0a43d1be 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -47,17 +47,6 @@ describe("cookieScope", () => { }); }); - it("preserves a domain cookie's leading dot", () => { - expect(cookieScope(".example.test", "/app", true)).toEqual({ - url: "https://example.test/app", - domain: ".example.test", - }); - }); - - it("matches the scheme to the secure flag", () => { - expect(cookieScope("example.test", "/", false).url).toBe("http://example.test/"); - }); - it("brackets bare IPv6 hosts without duplicating existing brackets", () => { expect(cookieScope("::1", "/", false)).toEqual({ url: "http://[::1]/", @@ -338,62 +327,6 @@ describe("readChromiumCookieDatabase", () => { }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); - it.effect("preserves arbitrary long encrypted values from pre-24 schemas", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-chromium-cookies-", - }); - const filename = `${directory}/Cookies`; - const key = Buffer.from("0123456789abcdef"); - const value = "x".repeat(32) + " legacy value"; - - yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`create table meta (key text primary key, value text not null)`; - yield* sql`insert into meta values ('version', 23)`; - yield* sql` - create table cookies ( - host_key text not null, name text not null, value text not null, - encrypted_value blob not null, path text not null, expires_utc integer not null, - is_secure integer not null, is_httponly integer not null, samesite integer not null, - top_frame_site_key text not null default '' - ) - `; - yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values - ('legacy.example', 'legacy', '', ${encryptV10(value, key)}, '/', 0, 0, 0, 0)`; - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); - - const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); - expect(result.cookies[0]?.value).toBe(value); - expect(result.undecryptable).toBe(0); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("rejects a malformed text schema version", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-chromium-cookies-", - }); - const filename = `${directory}/Cookies`; - - yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`create table meta (key text primary key, value text not null)`; - yield* sql`insert into meta values ('version', 'not-a-version')`; - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); - - const error = yield* readChromiumCookieDatabase( - filename, - { cbcV10: Buffer.from("0123456789abcdef") }, - "darwin", - ).pipe(Effect.flip); - - expect(error._tag).toBe("SchemaError"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - it.effect("treats unversioned encrypted values as legacy plaintext on macOS and Linux", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts deleted file mode 100644 index 890d9e271586..000000000000 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import { ChildProcessSpawner } from "effect/process"; -import { vi } from "vite-plus/test"; - -vi.mock("@napi-rs/keyring", () => { - throw new Error("Cannot find native binding"); -}); - -it.effect("reports an unavailable keychain when the macOS binding cannot load", () => - Effect.gen(function* () { - const { ChromiumKeyError, resolveChromiumKeys } = yield* Effect.promise( - () => import("./ChromiumKeys.ts"), - ); - const error = yield* resolveChromiumKeys({ - platform: "darwin", - keychainService: "Chrome Safe Storage", - keychainAccount: "Chrome", - linuxSecretApplication: undefined, - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(ChromiumKeyError); - expect(error.reason).toBe("keychainUnavailable"); - expect(error.cause).toBeInstanceOf(Error); - }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("must not spawn")), - ), - ), -); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts index cdeb67e0e8cc..6fc5b7ae9faa 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts @@ -96,14 +96,6 @@ describe("macOS Chromium secrets", () => { }).pipe(Effect.provide(layerNoProcesses)), ); - it.effect("reports a missing keychain entry", () => - Effect.gen(function* () { - getPassword.mockReturnValue(null); - const error = yield* resolveChromiumKeys(request).pipe(Effect.flip); - expect(error.reason).toBe("keychainItemMissing"); - }).pipe(Effect.provide(layerNoProcesses)), - ); - it.effect("preserves a denied keychain approval", () => Effect.gen(function* () { const denied = new Error("User denied access"); @@ -154,40 +146,6 @@ describe("Linux Chromium secrets", () => { ), ); - it.effect("looks up the browser's libsecret application attribute", () => { - let captured: CapturedCommand | undefined; - return Effect.gen(function* () { - const keys = yield* resolveChromiumKeys({ - platform: "linux", - keychainService: "ignored macOS service", - keychainAccount: "ignored macOS account", - linuxSecretApplication: "msedge", - }); - - expect(captured?.command).toBe("/bundled/browser-secret/t3-browser-secret"); - expect(captured?.args).toEqual(["msedge"]); - expect(captured?.options.stdin).toBe("ignore"); - expect(keys.cbcV10).toHaveLength(16); - expect(keys.cbcV11).toHaveLength(16); - }).pipe( - Effect.provide( - layerHelper({ stdout: "linux-secret", capture: (value) => (captured = value) }), - ), - ); - }); - - it.effect("reports an unavailable Secret Service backend as a read failure", () => - Effect.gen(function* () { - const error = yield* readLinuxSecret("chrome").pipe(Effect.flip); - expect(error).toBeInstanceOf(ChromiumKeyError); - expect(error.reason).toBe("keychainUnavailable"); - }).pipe( - Effect.provide( - layerHelper({ stderr: "Cannot autolaunch D-Bus without X11 $DISPLAY", exitCode: 1 }), - ), - ), - ); - it.effect("preserves trailing whitespace in the stored secret", () => Effect.gen(function* () { const secret = yield* readLinuxSecret("chrome"); @@ -240,51 +198,6 @@ describe("Linux Chromium secrets", () => { ); }, ); - - it.effect("does not discard a denied unlock prompt while resolving keys", () => - Effect.gen(function* () { - const error = yield* resolveChromiumKeys({ - platform: "linux", - keychainService: undefined, - keychainAccount: undefined, - linuxSecretApplication: "brave", - }).pipe(Effect.flip); - expect(error.reason).toBe("needsKeychainApproval"); - }).pipe(Effect.provide(layerHelper({ stderr: "Keyring is locked", exitCode: 3 }))), - ); - - it.effect("keeps the v10 fallback when the Secret Service backend is unavailable", () => - Effect.gen(function* () { - const keys = yield* resolveChromiumKeys({ - platform: "linux", - keychainService: undefined, - keychainAccount: undefined, - linuxSecretApplication: "chrome", - }); - expect(keys.cbcV10).toHaveLength(16); - expect(keys.cbcV11).toBeUndefined(); - }).pipe( - Effect.provide( - layerHelper({ - stderr: "Cannot autolaunch D-Bus without X11 $DISPLAY", - exitCode: 1, - }), - ), - ), - ); - - it.effect("keeps the v10 fallback when no matching v11 secret exists", () => - Effect.gen(function* () { - const keys = yield* resolveChromiumKeys({ - platform: "linux", - keychainService: undefined, - keychainAccount: undefined, - linuxSecretApplication: "vivaldi", - }); - expect(keys.cbcV10).toHaveLength(16); - expect(keys.cbcV11).toBeUndefined(); - }).pipe(Effect.provide(layerHelper({ exitCode: 2 }))), - ); }); describe("Windows Chromium secrets", () => { diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts index 18bcd7581b4f..402397d3fde6 100644 --- a/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts @@ -62,23 +62,4 @@ describe("snapshotCookieDatabase", () => { }), ), ); - - it.effect("removes a successful snapshot when its scope closes", () => - runNode( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-cookie-cleanup-source-", - }); - const source = path.join(sourceDirectory, "Cookies"); - yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`; - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source }))); - const snapshot = yield* snapshotCookieDatabase(source).pipe(Effect.scoped); - expect(yield* fileSystem.exists(snapshot)).toBe(false); - }), - ), - ); }); diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts index 9f28387d4f14..d764b5bd6d00 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -370,14 +370,6 @@ describe("parseFirefoxProfiles", () => { }), ); - it.effect("falls back to the path when a profile has no name", () => - Effect.gen(function* () { - expect( - yield* parsePosixFirefoxProfiles(["[Profile0]", "Path=Profiles/x.default"].join("\n")), - ).toEqual([{ directory: "Profiles/x.default", name: "Profiles/x.default" }]); - }), - ); - it.effect.each([ { platform: "Linux", root: "/home/user/.mozilla/firefox" }, { platform: "macOS", root: "/Users/user/Library/Application Support/Firefox" }, diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts index fa369c8edc62..5420b9fc8bd5 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -11,7 +11,6 @@ import { parseBinaryCookies, readSafariCookies, safariAccessDenied, - safariAccessGranted, SafariCookieReadError, } from "./SafariCookies.ts"; @@ -138,17 +137,6 @@ describe("parseBinaryCookies", () => { }); }); - it("brackets IPv6 hosts in the cookie URL", () => { - const file = encodeBinaryCookies([ - { domain: "::1", name: "local", path: "/", value: "v", flags: 0, expiry: 0 }, - ]); - - expect(parseBinaryCookies(file)[0]).toMatchObject({ - url: "http://[::1]/", - domain: undefined, - }); - }); - it("reads cookies spread across multiple pages", () => { const first = encodeBinaryCookies([ { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, @@ -191,34 +179,6 @@ describe("parseBinaryCookies", () => { ); }); - it("rejects a record whose declared size runs past its page", () => { - const valid = encodeBinaryCookies([ - { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, - ]); - const pageStart = 8 + 4; - const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); - const corrupt = Buffer.from(valid); - corrupt.writeUInt32LE(0xffff, recordStart); - - expect(() => parseBinaryCookies(corrupt)).toThrow(SafariCookieReadError); - }); - - it("rejects records truncated inside the 56-byte header", () => { - const valid = encodeBinaryCookies([ - { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, - ]); - const pageStart = 8 + 4; - const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); - - for (let size = 48; size < 56; size += 1) { - const corrupt = Buffer.from(valid); - corrupt.writeUInt32LE(size, recordStart); - expect(() => parseBinaryCookies(corrupt), `record size ${size}`).toThrow( - SafariCookieReadError, - ); - } - }); - it("rejects record offsets that point into the page header or an earlier record", () => { const valid = encodeBinaryCookies([ { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, @@ -238,22 +198,6 @@ describe("parseBinaryCookies", () => { expect(parseBinaryCookies(valid)).toHaveLength(2); }); - it("rejects string offsets that point into the record header", () => { - const valid = encodeBinaryCookies([ - { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, - ]); - const pageStart = 8 + 4; - const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); - - for (const offsetField of [16, 20, 24, 28]) { - const corrupt = Buffer.from(valid); - corrupt.writeUInt32LE(55, recordStart + offsetField); - expect(() => parseBinaryCookies(corrupt), `offset field ${offsetField}`).toThrow( - SafariCookieReadError, - ); - } - }); - it("accepts the checksum and property-list trailer Safari writes", () => { const file = encodeBinaryCookies([ { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 }, @@ -266,48 +210,9 @@ describe("parseBinaryCookies", () => { expect(parseBinaryCookies(Buffer.concat([file, checksum]))).toHaveLength(1); expect(parseBinaryCookies(Buffer.concat([file, checksum, plistLength, plist]))).toHaveLength(1); }); - - it("rejects a jar whose page table stops short of its contents", () => { - const first = encodeBinaryCookies([ - { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 }, - ]); - const extraPage = encodeBinaryCookies([ - { domain: "b.test", name: "d", path: "/", value: "w", flags: 0, expiry: 0 }, - ]).subarray(12); - - expect(() => parseBinaryCookies(Buffer.concat([first, extraPage]))).toThrow( - SafariCookieReadError, - ); - const badLength = Buffer.alloc(4); - badLength.writeUInt32BE(99, 0); - expect(() => - parseBinaryCookies(Buffer.concat([first, Buffer.alloc(8), badLength, Buffer.from("x")])), - ).toThrow(SafariCookieReadError); - }); - - it("rejects a file that is not binarycookies", () => { - expect(() => parseBinaryCookies(Buffer.from("not a cookie jar"))).toThrow( - SafariCookieReadError, - ); - }); }); describe("readSafariCookies", () => { - it.effect("adds the cookie path and parser cause to malformed jar failures", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); - const jar = `${directory}/Cookies.binarycookies`; - yield* fileSystem.writeFileString(jar, "not a cookie jar"); - - const error = yield* readSafariCookies(jar).pipe(Effect.flip); - - assert.equal(error.reason, "readFailed"); - assert.equal(error.cookieDatabasePath, jar); - assert.instanceOf(error.cause, SafariCookieReadError); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - it.effect("reports a TCC denial as a permission the user can grant", () => Effect.gen(function* () { // What Full Disk Access actually looks like: the file is there, the read @@ -329,54 +234,9 @@ describe("readSafariCookies", () => { assert.equal(error.reason, "needsFullDiskAccess"); }), ); - - it.effect("reports an ordinary permission failure as a plain read failure", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); - const jar = `${directory}/Cookies.binarycookies`; - yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); - yield* fileSystem.chmod(jar, 0o000); - - const error = yield* readSafariCookies(jar).pipe(Effect.flip); - - assert.equal(error.reason, "readFailed"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("reports a missing jar as a plain read failure", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); - - const error = yield* readSafariCookies(`${directory}/absent.binarycookies`).pipe(Effect.flip); - - assert.equal(error.reason, "readFailed"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); }); describe("safariAccessDenied", () => { - const eperm = PlatformError.systemError({ - _tag: "Unknown", - module: "FileSystem", - method: "open", - pathOrDescriptor: "/protected/Cookies.binarycookies", - cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), - }); - const denied = (error: PlatformError.PlatformError) => - FileSystem.layerNoop({ open: () => Effect.fail(error) }); - - it.effect("reports TCC's EPERM as a missing Full Disk Access grant", () => - Effect.gen(function* () { - assert.isTrue( - yield* safariAccessDenied("/protected/Cookies.binarycookies").pipe( - Effect.provide(denied(eperm)), - ), - ); - }), - ); - it.effect("does not read a readable jar, or any other failure, as denied", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -393,48 +253,7 @@ describe("isPermissionDenied", () => { const platformError = (reasonTag: string, code: string): PlatformError.PlatformError => ({ _tag: "PlatformError", reason: { _tag: reasonTag, cause: { code } } }) as never; - it("treats a TCC EPERM denial as permission denied", () => { - expect(isPermissionDenied(platformError("Unknown", "EPERM"))).toBe(true); - }); - it("does not send an ordinary EACCES failure to the Full Disk Access grant", () => { expect(isPermissionDenied(platformError("PermissionDenied", "EACCES"))).toBe(false); }); - - it("does not treat an unrelated failure as permission denied", () => { - expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false); - }); -}); - -describe("safariAccessGranted", () => { - it.effect("only reports a successful read-only open as granted", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-safari-permission-", - }); - const jar = `${directory}/Cookies.binarycookies`; - assert.isFalse(yield* safariAccessGranted(jar)); - yield* fileSystem.writeFileString(jar, "no cookie parsing needed"); - assert.isTrue(yield* safariAccessGranted(jar)); - yield* fileSystem.remove(jar); - assert.isFalse(yield* safariAccessGranted(jar)); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("does not mistake TCC denial for a grant", () => - Effect.gen(function* () { - const denied = PlatformError.systemError({ - _tag: "Unknown", - module: "FileSystem", - method: "open", - cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), - }); - assert.isFalse( - yield* safariAccessGranted("/protected/Cookies.binarycookies").pipe( - Effect.provide(FileSystem.layerNoop({ open: () => Effect.fail(denied) })), - ), - ); - }), - ); }); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index 30bf5a7fd923..cc92563c5847 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -7,10 +7,8 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { ChildProcessSpawner } from "effect/process"; import * as NodeSqlite from "node:sqlite"; import type { BrowserImportPathContext } from "./Sources.ts"; @@ -18,13 +16,10 @@ import { BROWSER_IMPORT_SOURCES, chromiumProcessIsAlive, chromiumSingletonLockIsHeld, - cookieDatabaseCandidatePaths, firefoxSymlinkLockIsHeld, resolveCookieDatabase, isSourceInstalled, isSourceRunning, - isWindowsLockHeldError, - posixLockIsHeld, listSourceProfiles, sourcePathContext, windowsChromiumCookiesAreHeld, @@ -33,44 +28,6 @@ import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; -describe("Linux Chromium secret applications", () => { - it("pins the libsecret application attribute for each supported fork", () => { - assert.deepEqual( - Object.fromEntries( - BROWSER_IMPORT_SOURCES.filter((source) => source.platforms.includes("linux")).map( - (source) => [source.id, source.linuxSecretApplication], - ), - ), - { - chrome: "chrome", - edge: "msedge", - brave: "brave", - vivaldi: "vivaldi", - opera: "opera", - helium: "chromium", - firefox: undefined, - }, - ); - }); -}); - -const platformError = (reasonTag: string): PlatformError.PlatformError => - ({ _tag: "PlatformError", reason: { _tag: reasonTag } }) as never; - -describe("Windows browser lock errors", () => { - it("treats sharing and lock violations reported as Busy as held", () => { - assert.isTrue(isWindowsLockHeldError(platformError("Busy"))); - }); - - it("does not treat access denied as proof of an active lock", () => { - assert.isFalse(isWindowsLockHeldError(platformError("PermissionDenied"))); - }); - - it("does not treat a missing lock file as held", () => { - assert.isFalse(isWindowsLockHeldError(platformError("NotFound"))); - }); -}); - const withSourceHome = Effect.fnUntraced(function* () { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" }); @@ -119,69 +76,6 @@ const writeFirefoxCookieDatabase = ( database.close(); }); -describe("Helium on Linux", () => { - it.effect.skipIf(!symlinksSupported)("discovers its profiles and checks the user-data lock", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-helium-linux-" }); - const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcess.Environment, { HOME: home }), - Effect.provideService(HostProcess.Platform, "linux"), - ); - const root = `${home}/.config/net.imput.helium`; - yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); - yield* writeCookieDatabase(`${root}/Default/Cookies`, 3); - yield* fileSystem.writeFileString( - `${root}/Local State`, - '{"profile":{"info_cache":{"Default":{"name":"Personal"}}}}', - ); - - assert.include(helium.platforms, "linux"); - assert.isTrue(yield* isSourceInstalled(helium, context)); - assert.deepEqual(yield* listSourceProfiles(helium, context), [ - { directory: "Default", name: "Personal", cookieCount: 3 }, - ]); - assert.isFalse(yield* isSourceRunning(helium, context)); - yield* fileSystem.symlink("foreign-host-4242", `${root}/SingletonLock`); - assert.isTrue(yield* isSourceRunning(helium, context)); - }), - ), - ); -}); - -describe("Helium on Windows", () => { - it.effect("uses Helium's local app-data profile while other Chromium forks stay disabled", () => - run( - Effect.gen(function* () { - const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcess.Environment, { - USERPROFILE: "C:\\Users\\browser-user", - LOCALAPPDATA: "C:\\Users\\browser-user\\AppData\\Local", - }), - Effect.provideService(HostProcess.Platform, "win32"), - ); - - assert.include(helium.platforms, "win32"); - assert.equal( - helium.userDataDirectory(context), - context.path.join( - "C:\\Users\\browser-user\\AppData\\Local", - "imput", - "Helium", - "User Data", - ), - ); - for (const source of BROWSER_IMPORT_SOURCES) { - if (source.engine === "chromium" && source.id !== "helium") { - assert.notInclude(source.platforms, "win32"); - } - } - }), - ), - ); -}); - describe("isSourceRunning", () => { it.effect("uses the held cookie database as Chromium's Windows running signal", () => run( @@ -237,32 +131,6 @@ describe("isSourceRunning", () => { }), ), ); - - it.effect.skipIf(!symlinksSupported)( - "uses the provided hostname to classify Chromium locks", - () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - yield* fileSystem.symlink( - "lock-owner-99999999", - `${helium.userDataDirectory(paths)}/SingletonLock`, - ); - - assert.isTrue( - yield* isSourceRunning(helium, paths).pipe( - Effect.provideService(HostProcess.Hostname, "another-host"), - ), - ); - assert.isFalse( - yield* isSourceRunning(helium, paths).pipe( - Effect.provideService(HostProcess.Hostname, "lock-owner"), - ), - ); - }), - ), - ); }); describe("chromiumSingletonLockIsHeld", () => { @@ -280,12 +148,6 @@ describe("chromiumSingletonLockIsHeld", () => { }), ); - it.effect("keeps a live PID on the current host", () => - chromiumSingletonLockIsHeld("current-host-4321", "current-host", () => - Effect.succeed(true), - ).pipe(Effect.tap((held) => Effect.sync(() => assert.isTrue(held)))), - ); - it.effect("keeps foreign-host and malformed targets without probing a PID", () => Effect.gen(function* () { let probes = 0; @@ -367,81 +229,9 @@ describe("isSourceInstalled", () => { }), ), ); - - it.effect("detects a Chromium 127+ install with cookies under Network/", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const context = yield* withSourceHome(); - const root = userDataDirectory(context); - - yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); - yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "db"); - assert.isTrue(yield* isSourceInstalled(helium, context)); - }), - ), - ); - - it.effect.skipIf(!symlinksSupported)( - "follows cookie database symlinks when detecting profiles", - () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const context = yield* withSourceHome(); - const root = userDataDirectory(context); - yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); - yield* fileSystem.symlink("missing-cookies", `${root}/Default/Cookies`); - - assert.deepEqual(yield* listSourceProfiles(helium, context), []); - assert.isFalse(yield* isSourceInstalled(helium, context)); - - yield* fileSystem.writeFileString(`${root}/Default/missing-cookies`, "db"); - assert.deepEqual(yield* listSourceProfiles(helium, context), [ - { directory: "Default", name: "Default" }, - ]); - assert.isTrue(yield* isSourceInstalled(helium, context)); - }), - ), - ); }); describe("listSourceProfiles", () => { - it.effect("ignores a profile whose Cookies entry is not a file", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - const root = helium.userDataDirectory(paths); - yield* fileSystem.makeDirectory(`${root}/Broken/Cookies`, { recursive: true }); - yield* fileSystem.makeDirectory(`${root}/Real`, { recursive: true }); - yield* fileSystem.writeFileString(`${root}/Real/Cookies`, "db"); - - assert.deepEqual(yield* listSourceProfiles(helium, paths), [ - { directory: "Real", name: "Real" }, - ]); - assert.isTrue(yield* isSourceInstalled(helium, paths)); - }), - ), - ); - - it.effect("discovers profiles by their cookie database when Local State is absent", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const context = yield* withSourceHome(); - const root = userDataDirectory(context); - yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); - yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); - yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); - - assert.deepEqual(yield* listSourceProfiles(helium, context), [ - { directory: "Profile 1", name: "Profile 1" }, - ]); - }), - ), - ); - it.effect("reads the profile names the browser shows", () => run( Effect.gen(function* () { @@ -477,72 +267,6 @@ describe("listSourceProfiles", () => { ), ); - it.effect("reports nothing when no directory holds a cookie database", () => - run( - Effect.gen(function* () { - const context = yield* withSourceHome(); - assert.deepEqual(yield* listSourceProfiles(helium, context), []); - }), - ), - ); - - it.effect("drops Firefox profiles that hold no cookie database", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const context = yield* withSourceHome(); - const root = firefox.userDataDirectory(context)!; - yield* fileSystem.makeDirectory(root, { recursive: true }); - yield* fileSystem.writeFileString( - `${root}/profiles.ini`, - `[Profile0] -Name=original -IsRelative=1 -Path=Profiles/abcd.default-release -Default=1 - -[Profile1] -Name=empty -IsRelative=1 -Path=Profiles/wxyz.empty -`, - ); - yield* fileSystem.makeDirectory(`${root}/Profiles/abcd.default-release`, { - recursive: true, - }); - yield* fileSystem.writeFileString( - `${root}/Profiles/abcd.default-release/cookies.sqlite`, - "db", - ); - yield* fileSystem.makeDirectory(`${root}/Profiles/wxyz.empty`, { recursive: true }); - - assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { directory: context.path.join("Profiles", "abcd.default-release"), name: "original" }, - ]); - }), - ), - ); - - it.effect("drops empty profiles when falling back to the Profiles/ scan", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const context = yield* withSourceHome(); - const root = firefox.userDataDirectory(context)!; - yield* fileSystem.makeDirectory(`${root}/Profiles/filled.default`, { recursive: true }); - yield* fileSystem.writeFileString(`${root}/Profiles/filled.default/cookies.sqlite`, "db"); - yield* fileSystem.makeDirectory(`${root}/Profiles/empty.default`, { recursive: true }); - - assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { - directory: context.path.join("Profiles", "filled.default"), - name: "filled.default", - }, - ]); - }), - ), - ); - it.effect("discovers profiles with cookies under Network/ (Chromium 127+)", () => run( Effect.gen(function* () { @@ -559,21 +283,6 @@ Path=Profiles/wxyz.empty ), ); - it.effect("counts a profile's cookies without decrypting them", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - const root = helium.userDataDirectory(paths); - yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); - yield* writeCookieDatabase(`${root}/Default/Cookies`, 3); - - const [profile] = yield* listSourceProfiles(helium, paths); - assert.equal(profile?.cookieCount, 3); - }), - ), - ); - it.effect("falls through to the legacy database when Network/Cookies is a directory", () => run( Effect.gen(function* () { @@ -592,25 +301,6 @@ Path=Profiles/wxyz.empty }); describe("cookieDatabaseCandidatePaths", () => { - it.effect("prefers Network/Cookies and falls back to the legacy Cookies", () => - run( - Effect.gen(function* () { - const context = yield* withSourceHome(); - const profile = context.path.join( - context.home, - "Library", - "Application Support", - "net.imput.helium", - "Profile 1", - ); - assert.deepEqual(cookieDatabaseCandidatePaths(helium, context, "Profile 1"), [ - context.path.join(profile, "Network", "Cookies"), - context.path.join(profile, "Cookies"), - ]); - }), - ), - ); - it.effect("resolves the live Network/ jar over a leftover root Cookies", () => run( Effect.gen(function* () { @@ -632,25 +322,6 @@ describe("cookieDatabaseCandidatePaths", () => { }), ), ); - - it.effect("returns only cookies.sqlite for Firefox", () => - run( - Effect.gen(function* () { - const path = yield* Path.Path; - const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcess.Environment, { HOME: "/tmp/test" }), - Effect.provideService(HostProcess.Platform, "darwin"), - ); - const candidates = cookieDatabaseCandidatePaths(firefox, context, "Profiles/abc.default"); - assert.deepEqual(candidates, [ - path.join( - "/tmp/test", - "Library/Application Support/Firefox/Profiles/abc.default/cookies.sqlite", - ), - ]); - }), - ), - ); }); const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!; @@ -908,68 +579,6 @@ describe("isSourceRunning for Firefox", () => { ), ); - it.effect("reports not-held when no interpreter can run the fcntl probe", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-lock-" }); - const lock = `${directory}/.parentlock`; - yield* fileSystem.writeFileString(lock, ""); - assert.isFalse(yield* posixLockIsHeld(lock, ["/nonexistent/python3"])); - assert.isFalse(yield* posixLockIsHeld(lock, ["/usr/bin/false"])); - }), - ), - ); - - // Holds the lock with python3's fcntl, which does not exist on Windows. - it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( - "detects a live fcntl lock on .parentlock, as macOS Firefox leaves it", - () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); - const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcess.Environment, { HOME: home }), - Effect.provideService(HostProcess.Platform, "darwin"), - ); - const root = firefox.userDataDirectory(context)!; - const profile = `${root}/Profiles/abcd.default-release`; - yield* fileSystem.makeDirectory(profile, { recursive: true }); - yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); - const parentLock = `${profile}/.parentlock`; - yield* fileSystem.writeFileString(parentLock, ""); - - const holder = yield* spawner.spawn( - ChildProcess.make( - "python3", - [ - "-c", - "import fcntl,os,sys,time\n" + - "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + - "fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" + - "print('locked',flush=True)\n" + - "time.sleep(30)", - parentLock, - ], - { stdin: "ignore" }, - ), - ); - yield* holder.stdout.pipe( - Stream.decodeText(), - Stream.splitLines, - Stream.filter((line) => line.trim() === "locked"), - Stream.take(1), - Stream.runDrain, - ); - - assert.isTrue(yield* isSourceRunning(firefox, context)); - yield* holder.kill(); - }), - ), - ); - it.effect("reads a Firefox lock symlink's pid to tell live from crashed", () => Effect.gen(function* () { const alive = (pid: number) => Effect.succeed(pid === 4242); @@ -1016,18 +625,6 @@ describe("isSourceRunning for Firefox", () => { ); }); -describe("Windows user-data directories", () => { - it.effect("keeps app-bound Chromium forks unsupported on win32", () => - Effect.sync(() => { - for (const source of BROWSER_IMPORT_SOURCES) { - if (source.engine === "chromium" && source.id !== "helium") { - assert.notInclude(source.platforms, "win32"); - } - } - }), - ); -}); - describe("listSourceProfiles hardening", () => { it.effect("drops profile directories that are not a single plain segment", () => run( @@ -1139,16 +736,4 @@ describe("Safari profiles", () => { }), ), ); - - it.effect("keeps Safari without profiles available", () => - run( - Effect.gen(function* () { - const context = yield* withSourceHome(); - assert.deepEqual(yield* listSourceProfiles(safari, context), [ - { directory: ".", name: "Safari" }, - ]); - assert.isFalse(yield* isSourceInstalled(safari, context)); - }), - ), - ); }); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 59a89eadfa73..3e1cae508a69 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -174,7 +174,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray +const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean => error.reason._tag === "Busy"; /** @@ -671,7 +671,7 @@ const FCNTL_PROBE_SCRIPT = * block Firefox import outright on such machines, and the SQLite snapshot * already copes with a live database's WAL, as it does for every other engine. */ -export const posixLockIsHeld = Effect.fnUntraced(function* ( +const posixLockIsHeld = Effect.fnUntraced(function* ( path: string, interpreters: ReadonlyArray = FCNTL_PROBE_INTERPRETERS, ) { diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index d421898732a1..45d2aeafdbfa 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -1,9 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; import { beforeEach, vi } from "vite-plus/test"; const { fromPartition, sessions } = vi.hoisted(() => ({ @@ -235,119 +233,6 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); - it.effect("attaches the open-externally prompt to the window hosting the preview", () => - Effect.gen(function* () { - const browserSessions = yield* BrowserSession.BrowserSession; - const partition = yield* browserSessions.getPartition("scope-a"); - yield* browserSessions.getSession("scope-a"); - const requestHandler = - sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0]; - assert.isFunction(requestHandler); - - const hostWindow = { id: 7 } as unknown as Electron.BrowserWindow; - const host = { isDestroyed: () => false }; - const guest = { isDestroyed: () => false, hostWebContents: host }; - fromWebContents.mockImplementation((webContents) => - webContents === host ? hostWindow : null, - ); - showMessageBox.mockReset(); - showMessageBox.mockReturnValueOnce(1); - - yield* Effect.callback((resume) => { - requestHandler( - guest, - "openExternal", - (granted: boolean) => resume(Effect.succeed(granted)), - { - externalURL: "slack://open?team=T1", - }, - ); - }); - - assert.strictEqual(showMessageBox.mock.calls[0]?.[1], hostWindow); - }).pipe(Effect.provide(layer)), - ); - - it.effect("preserves partition scope and the platform failure chain", () => { - const nativeCause = new Error("native digest failed"); - const platformCause = PlatformError.systemError({ - _tag: "Unknown", - module: "Crypto", - method: "digest", - cause: nativeCause, - }); - const layerFailingCrypto = Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size), - digest: () => Effect.fail(platformCause), - }), - ); - - return Effect.gen(function* () { - const browserSessions = yield* BrowserSession.BrowserSession; - const error = yield* browserSessions.getPartition("environment-a").pipe(Effect.flip); - - assert.instanceOf(error, BrowserSession.BrowserSessionPartitionDerivationError); - assert.equal(error.scope, "environment-a"); - assert.strictEqual(error.cause, platformCause); - assert.strictEqual(error.cause.reason.cause, nativeCause); - assert.equal( - error.message, - "Failed to derive a desktop preview browser partition for scope environment-a.", - ); - assert.notInclude(error.message, nativeCause.message); - }).pipe( - Effect.provide( - BrowserSession.layer.pipe(Layer.provide(Layer.merge(layerFailingCrypto, layerDialog))), - ), - ); - }); - - it.effect("preserves session scope, partition, and the Electron failure", () => - Effect.gen(function* () { - const cause = new Error("Electron session failed"); - fromPartition.mockImplementationOnce(() => { - throw cause; - }); - const browserSessions = yield* BrowserSession.BrowserSession; - const partition = yield* browserSessions.getPartition("environment-b"); - const error = yield* browserSessions.getSession("environment-b").pipe(Effect.flip); - - assert.instanceOf(error, BrowserSession.BrowserSessionCreationError); - assert.equal(error.scope, "environment-b"); - assert.equal(error.partition, partition); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - `Failed to create a desktop preview browser session for scope environment-b (partition ${partition}).`, - ); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(layer)), - ); - - it.effect("clears storage and cache for every created session", () => - Effect.gen(function* () { - const browserSessions = yield* BrowserSession.BrowserSession; - yield* browserSessions.getSession("scope-a"); - yield* browserSessions.getSession("scope-b"); - - yield* browserSessions.clearCookies(); - yield* browserSessions.clearCache(); - - assert.strictEqual(sessions.size, 2); - for (const browserSession of sessions.values()) { - assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1); - assert.deepEqual(browserSession.clearStorageData.mock.calls[0], [ - { - storages: ["cookies", "localstorage", "indexdb", "serviceworkers"], - }, - ]); - assert.strictEqual(browserSession.clearCache.mock.calls.length, 1); - } - }).pipe(Effect.provide(layer)), - ); - it.effect("clears a partition whose session has not been opened yet", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; @@ -365,50 +250,4 @@ describe("BrowserSession", () => { assert.strictEqual(created.clearStorageData.mock.calls.length, 1); }).pipe(Effect.provide(layer)), ); - - it.effect("correlates clear failures while still attempting every session", () => - Effect.gen(function* () { - const browserSessions = yield* BrowserSession.BrowserSession; - yield* browserSessions.getSession("scope-a"); - yield* browserSessions.getSession("scope-b"); - const firstPartition = yield* browserSessions.getPartition("scope-a"); - const secondPartition = yield* browserSessions.getPartition("scope-b"); - const firstSession = sessions.get(firstPartition); - const secondSession = sessions.get(secondPartition); - assert.isDefined(firstSession); - assert.isDefined(secondSession); - - const storageCause = new Error("storage clear failed"); - secondSession.clearStorageData.mockImplementationOnce(() => Promise.reject(storageCause)); - const storageError = yield* browserSessions.clearCookies().pipe(Effect.flip); - - assert.instanceOf(storageError, BrowserSession.BrowserSessionStorageClearError); - assert.equal(storageError.partition, secondPartition); - assert.strictEqual(storageError.cause, storageCause); - assert.equal( - storageError.message, - `Failed to clear desktop preview browser storage for partition ${secondPartition}.`, - ); - assert.notInclude(storageError.message, storageCause.message); - for (const browserSession of sessions.values()) { - assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1); - } - - const cacheCause = new Error("cache clear failed"); - firstSession.clearCache.mockImplementationOnce(() => Promise.reject(cacheCause)); - const cacheError = yield* browserSessions.clearCache().pipe(Effect.flip); - - assert.instanceOf(cacheError, BrowserSession.BrowserSessionCacheClearError); - assert.equal(cacheError.partition, firstPartition); - assert.strictEqual(cacheError.cause, cacheCause); - assert.equal( - cacheError.message, - `Failed to clear the desktop preview browser cache for partition ${firstPartition}.`, - ); - assert.notInclude(cacheError.message, cacheCause.message); - for (const browserSession of sessions.values()) { - assert.strictEqual(browserSession.clearCache.mock.calls.length, 1); - } - }).pipe(Effect.provide(layer)), - ); }); diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 5932db57a863..2d17a2874454 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -15,6 +15,7 @@ const makeTarget = ( }); // Each relay reply resolves after a few microtasks; one macrotask lets them all land. +// oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- a zero-delay macrotask boundary, not a wait for time const settle = () => new Promise((resolve) => setImmediate(resolve)); describe("CDP relay", () => { diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 0d281c46f860..04dcd22cf3b2 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -82,7 +82,6 @@ describe("DesktopBrowserHost", () => { yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); yield* command(2, "DOM.enable"); debuggee.release(); - yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); const [, reply] = yield* Fiber.join(reader); expect(reply).toMatchObject({ type: "cdp" }); diff --git a/apps/desktop/src/preview/FaviconCapture.test.ts b/apps/desktop/src/preview/FaviconCapture.test.ts index 8446116bcd4c..2163dc002b70 100644 --- a/apps/desktop/src/preview/FaviconCapture.test.ts +++ b/apps/desktop/src/preview/FaviconCapture.test.ts @@ -140,102 +140,6 @@ function sourceJpegWithApp1Segments( return Buffer.concat([frame.subarray(0, 2), ...segments, frame.subarray(2)]); } -function sourceJpegWithOrientationEntries( - width: number, - height: number, - orientations: ReadonlyArray, -): Buffer { - return sourceJpegWithApp1Segments(width, height, [sourceJpegExifSegment(orientations)]); -} - -function sourceJpegWithEndianAlias(alias: number, byteOrder: "II" | "MM"): Buffer { - const exif = sourceJpegExifSegment([6], { byteOrder }); - exif[10] = alias; - exif[11] = alias; - return sourceJpegWithApp1Segments(64, 32, [exif]); -} - -function sourceJpegExifWithSubIfd(options: { - readonly rootOrientation?: number; - readonly subIfdFirst?: boolean; - readonly subIfdOrientation: number; -}): Buffer { - const rootEntries = options.rootOrientation === undefined ? 1 : 2; - const rootIfdOffset = 14; - const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4; - const exif = Buffer.alloc(subIfdOffset + 2 + 12 + 4); - exif.write("Exif\0\0", 0, "binary"); - exif.write("II", 6, "ascii"); - exif.writeUInt16LE(42, 8); - exif.writeUInt32LE(8, 10); - exif.writeUInt16LE(rootEntries, rootIfdOffset); - - const writeOrientation = (offset: number, orientation: number) => { - exif.writeUInt16LE(0x0112, offset); - exif.writeUInt16LE(3, offset + 2); - exif.writeUInt32LE(1, offset + 4); - exif.writeUInt16LE(orientation, offset + 8); - }; - const writeSubIfdPointer = (offset: number) => { - exif.writeUInt16LE(0x8769, offset); - exif.writeUInt16LE(4, offset + 2); - exif.writeUInt32LE(1, offset + 4); - exif.writeUInt32LE(subIfdOffset - 6, offset + 8); - }; - - const firstRootEntryOffset = rootIfdOffset + 2; - if (options.rootOrientation === undefined) { - writeSubIfdPointer(firstRootEntryOffset); - } else if (options.subIfdFirst) { - writeSubIfdPointer(firstRootEntryOffset); - writeOrientation(firstRootEntryOffset + 12, options.rootOrientation); - } else { - writeOrientation(firstRootEntryOffset, options.rootOrientation); - writeSubIfdPointer(firstRootEntryOffset + 12); - } - - exif.writeUInt16LE(1, subIfdOffset); - writeOrientation(subIfdOffset + 2, options.subIfdOrientation); - return sourceJpegApp1Segment(exif); -} - -function sourceJpegExifWithSubIfdPointers(options: { - readonly pointerCount: number; - readonly subIfdEntries: number; -}): Buffer { - const { pointerCount, subIfdEntries } = options; - const rootIfdOffset = 14; - const rootEntries = pointerCount + 1; - const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4; - const exif = Buffer.alloc(subIfdOffset + 2 + subIfdEntries * 12 + 4); - exif.write("Exif\0\0", 0, "binary"); - exif.write("II", 6, "ascii"); - exif.writeUInt16LE(42, 8); - exif.writeUInt32LE(8, 10); - exif.writeUInt16LE(rootEntries, rootIfdOffset); - for (let index = 0; index < pointerCount; index += 1) { - const entryOffset = rootIfdOffset + 2 + index * 12; - exif.writeUInt16LE(0x8769, entryOffset); - exif.writeUInt16LE(4, entryOffset + 2); - exif.writeUInt32LE(1, entryOffset + 4); - exif.writeUInt32LE(subIfdOffset - 6, entryOffset + 8); - } - const orientationOffset = rootIfdOffset + 2 + pointerCount * 12; - exif.writeUInt16LE(0x0112, orientationOffset); - exif.writeUInt16LE(3, orientationOffset + 2); - exif.writeUInt32LE(1, orientationOffset + 4); - exif.writeUInt16LE(6, orientationOffset + 8); - - exif.writeUInt16LE(subIfdEntries, subIfdOffset); - for (let index = 0; index < subIfdEntries; index += 1) { - const entryOffset = subIfdOffset + 2 + index * 12; - exif.writeUInt16LE(1, entryOffset); - exif.writeUInt16LE(3, entryOffset + 2); - exif.writeUInt32LE(1, entryOffset + 4); - } - return sourceJpegApp1Segment(exif); -} - function sourceJpegExifWithOverlappingSubIfds(pointerCount: number, subIfdEntries: number): Buffer { const rootIfdOffset = 14; const rootEntries = pointerCount + 1; @@ -288,13 +192,6 @@ function makeUnsafePng(): Buffer { return buffer; } -function sourcePng(width: number, height: number): Buffer { - const buffer = Buffer.from(SOURCE_PNG); - buffer.writeUInt32BE(width, 16); - buffer.writeUInt32BE(height, 20); - return buffer; -} - function makeUnsafeDib(): Buffer { const buffer = Buffer.alloc(40); buffer.writeUInt32LE(40, 0); @@ -328,40 +225,6 @@ function makeWebContents(options?: { }; } -const JPEG_LANDSCAPE_LAYOUT = { - draw: "context.drawImage(bitmap, 0, 8, 32, 16)", - resizeHeight: 16, - resizeWidth: 32, -} as const; -const JPEG_PORTRAIT_LAYOUT = { - draw: "context.drawImage(bitmap, 8, 0, 16, 32)", - resizeHeight: 32, - resizeWidth: 16, -} as const; - -async function expectJpegLayout( - source: Buffer, - layout: typeof JPEG_LANDSCAPE_LAYOUT | typeof JPEG_PORTRAIT_LAYOUT, -): Promise { - const { webContents } = makeWebContents({ - rasterize: async (code) => { - expect(code).toContain(`resizeWidth: ${layout.resizeWidth}`); - expect(code).toContain(`resizeHeight: ${layout.resizeHeight}`); - expect(code).toContain(layout.draw); - return PNG; - }, - }); - - expect( - await captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: [`data:image/jpeg;base64,${source.toString("base64")}`], - signal: new AbortController().signal, - }), - ).toEqual({ kind: "captured", dataUrl: PNG }); -} - describe("selectFaviconCandidates", () => { it("filters and deduplicates before applying the candidate cap", () => { const valid = Array.from( @@ -461,35 +324,6 @@ describe("captureFavicon", () => { expect(fetch).toHaveBeenCalledTimes(2); }); - it("cancels a rejected response body before trying the next candidate", async () => { - const cancel = vi.fn(); - const body = new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(1)); - }, - cancel, - }); - const { webContents, fetch } = makeWebContents({ - fetch: async (url) => - url.endsWith("first.png") - ? new Response(body, { status: 404 }) - : new Response(new Uint8Array(SOURCE_PNG), { - headers: { "content-type": "image/png" }, - }), - }); - - expect( - await captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: ["https://example.com/first.png", "https://example.com/second.png"], - signal: new AbortController().signal, - }), - ).toEqual({ kind: "captured", dataUrl: PNG }); - expect(cancel).toHaveBeenCalledOnce(); - expect(fetch).toHaveBeenCalledTimes(2); - }); - it("stops a pending fetch when its capture is aborted", async () => { const controller = new AbortController(); const { webContents } = makeWebContents({ @@ -510,102 +344,6 @@ describe("captureFavicon", () => { expect(await capture).toEqual({ kind: "none" }); }); - it("ends candidate fallback when the overall capture deadline expires", async () => { - const timeoutController = new AbortController(); - const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); - const { webContents, fetch } = makeWebContents({ - fetch: (url, init) => { - if (url.endsWith("first.png")) return Promise.resolve(new Response(null, { status: 404 })); - return new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { - once: true, - }); - }); - }, - }); - try { - const capture = captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: [ - "https://example.com/first.png", - "https://example.com/second.png", - "https://example.com/third.png", - ], - signal: new AbortController().signal, - }); - await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - timeoutController.abort(new DOMException("capture timed out", "TimeoutError")); - - expect(await capture).toEqual({ kind: "timed-out" }); - expect(fetch).toHaveBeenCalledTimes(2); - expect(timeout).toHaveBeenCalledOnce(); - } finally { - timeout.mockRestore(); - } - }); - - it("does not publish a rasterization that completes after the capture deadline", async () => { - const captureTimeoutController = new AbortController(); - const rasterTimeoutController = new AbortController(); - const timeout = vi - .spyOn(AbortSignal, "timeout") - .mockImplementation((milliseconds) => - milliseconds === 5_000 ? captureTimeoutController.signal : rasterTimeoutController.signal, - ); - let resolveRasterization!: (value: unknown) => void; - const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({ - rasterize: () => - new Promise((resolve) => { - resolveRasterization = resolve; - }), - }); - try { - const capture = captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: [SOURCE_PNG_URL], - signal: new AbortController().signal, - }); - await vi.waitFor(() => expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce()); - captureTimeoutController.abort(new DOMException("capture timed out", "TimeoutError")); - - expect(await capture).toEqual({ kind: "timed-out" }); - resolveRasterization(PNG); - } finally { - timeout.mockRestore(); - } - }); - - it("cancels a stalled response body when the capture deadline expires", async () => { - const timeoutController = new AbortController(); - const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); - const cancel = vi.fn(); - const { webContents } = makeWebContents({ - fetch: async () => - new Response( - new ReadableStream({ - cancel, - }), - { headers: { "content-type": "image/png" } }, - ), - }); - try { - const capture = captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: ["https://example.com/favicon.png"], - signal: new AbortController().signal, - }); - timeoutController.abort(new DOMException("capture timed out", "TimeoutError")); - - expect(await capture).toEqual({ kind: "timed-out" }); - expect(cancel).toHaveBeenCalledOnce(); - } finally { - timeout.mockRestore(); - } - }); - it("rejects and cancels an oversized streamed response", async () => { const cancel = vi.fn(); const body = new ReadableStream({ @@ -631,178 +369,6 @@ describe("captureFavicon", () => { expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); }); - it("retains bounded compatibility with common favicon formats", async () => { - const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); - for (const [mime, buffer] of [ - ["image/gif", sourceGif(32, 32)], - ["image/jpeg", sourceJpeg(32, 32)], - ["image/webp", sourceWebp(32, 32)], - ["image/x-icon", sourceIco(SOURCE_PNG)], - ] as const) { - expect( - await captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: [`data:${mime};base64,${buffer.toString("base64")}`], - signal: new AbortController().signal, - }), - ).toEqual({ kind: "captured", dataUrl: PNG }); - } - expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(4); - }); - - it.each([ - { - label: "landscape", - source: sourcePng(64, 32), - resizeWidth: 32, - resizeHeight: 16, - draw: "context.drawImage(bitmap, 0, 8, 32, 16)", - }, - { - label: "portrait", - source: sourcePng(32, 64), - resizeWidth: 16, - resizeHeight: 32, - draw: "context.drawImage(bitmap, 8, 0, 16, 32)", - }, - ])("preserves $label aspect ratio within the 32x32 output", async (testCase) => { - const { webContents } = makeWebContents({ - rasterize: async (code) => { - expect(code).toContain(`resizeWidth: ${testCase.resizeWidth}`); - expect(code).toContain(`resizeHeight: ${testCase.resizeHeight}`); - expect(code).toContain('resizeQuality: "high"'); - expect(code).toContain(testCase.draw); - return PNG; - }, - }); - - expect( - await captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: [`data:image/png;base64,${testCase.source.toString("base64")}`], - signal: new AbortController().signal, - }), - ).toEqual({ kind: "captured", dataUrl: PNG }); - }); - - it.each([ - ...[1, 2, 3, 4].map((orientation) => ({ - label: `keeps stored dimensions for orientation ${orientation}`, - layout: JPEG_LANDSCAPE_LAYOUT, - source: sourceJpeg(64, 32, orientation), - })), - ...[5, 6, 7, 8].map((orientation) => ({ - label: `uses display dimensions for orientation ${orientation}`, - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpeg(64, 32, orientation), - })), - { - label: "uses the first separate EXIF segment when it is transposed", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpeg(64, 32, [6, 1]), - }, - { - label: "uses the first separate EXIF segment when it is untransposed", - layout: JPEG_LANDSCAPE_LAYOUT, - source: sourceJpeg(64, 32, [1, 6]), - }, - { - label: "does not consult a later EXIF segment after an invalid orientation", - layout: JPEG_LANDSCAPE_LAYOUT, - source: sourceJpeg(64, 32, [9, 6]), - }, - { - label: "uses a later valid orientation in the same IFD", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithOrientationEntries(64, 32, [9, 6]), - }, - { - label: "skips a non-EXIF APP1 segment", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [ - sourceJpegApp1Segment(Buffer.from("not-exif")), - sourceJpegExifSegment([6]), - ]), - }, - { - label: "skips an empty EXIF APP1 segment", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [ - sourceJpegApp1Segment(Buffer.from("Exif\0\0", "binary")), - sourceJpegExifSegment([6]), - ]), - }, - { - label: "stops after a malformed qualifying EXIF APP1 segment", - layout: JPEG_LANDSCAPE_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [ - sourceJpegApp1Segment(Buffer.from("Exif\0\0broken", "binary")), - sourceJpegExifSegment([6]), - ]), - }, - { - label: "ignores the EXIF padding byte", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { padding: 0xff })]), - }, - { - label: "reads big-endian EXIF", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { byteOrder: "MM" })]), - }, - { - label: "matches Chromium for a nonstandard TIFF magic field", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { magic: 0 })]), - }, - { - label: "rejects a high-bit little-endian alias", - layout: JPEG_LANDSCAPE_LAYOUT, - source: sourceJpegWithEndianAlias(0xc9, "II"), - }, - { - label: "rejects a high-bit big-endian alias", - layout: JPEG_LANDSCAPE_LAYOUT, - source: sourceJpegWithEndianAlias(0xcd, "MM"), - }, - { - label: "reads an orientation from a SubIFD", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [ - sourceJpegExifWithSubIfd({ subIfdOrientation: 6 }), - ]), - }, - { - label: "uses a SubIFD orientation before a later root orientation", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [ - sourceJpegExifWithSubIfd({ - rootOrientation: 1, - subIfdFirst: true, - subIfdOrientation: 6, - }), - ]), - }, - { - label: "uses a root orientation before a later SubIFD orientation", - layout: JPEG_LANDSCAPE_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [ - sourceJpegExifWithSubIfd({ rootOrientation: 1, subIfdOrientation: 6 }), - ]), - }, - { - label: "memoizes repeated aliases to the same SubIFD", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [ - sourceJpegExifWithSubIfdPointers({ pointerCount: 32, subIfdEntries: 32 }), - ]), - }, - ])("matches Chromium JPEG layout: $label", async ({ source, layout }) => { - await expectJpegLayout(source, layout); - }); - it("rejects JPEG metadata when distinct SubIFDs exhaust the linear work budget", async () => { const source = sourceJpegWithApp1Segments(64, 32, [ sourceJpegExifWithOverlappingSubIfds(32, 32), @@ -820,41 +386,6 @@ describe("captureFavicon", () => { expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); }); - it("rejects JPEGs with multiple frame headers before rasterization", async () => { - const buffer = Buffer.concat([sourceJpeg(4096, 4096), sourceJpeg(1, 1).subarray(2)]); - const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); - - expect( - await captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: [`data:image/jpeg;base64,${buffer.toString("base64")}`], - signal: new AbortController().signal, - }), - ).toEqual({ kind: "none" }); - expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); - }); - - it("rejects an unsafe PNG size before rasterization", async () => { - const buffer = makeUnsafePng(); - const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({ - fetch: async () => - new Response(new Uint8Array(buffer), { - headers: { "content-type": "image/png" }, - }), - }); - - expect( - await captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: ["https://example.com/favicon.png"], - signal: new AbortController().signal, - }), - ).toEqual({ kind: "none" }); - expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); - }); - it.each([ ["GIF", "image/gif", sourceGif(4096, 4096)], ["GIF frame", "image/gif", sourceGif(1, 1, 4096, 4096)], @@ -908,85 +439,6 @@ describe("captureFavicon", () => { expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); }); - it("ignores output that is not a bounded PNG data URL", async () => { - const { webContents } = makeWebContents({ - rasterize: async () => "data:image/svg+xml;base64,c3Zn", - }); - - expect( - await captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: [SOURCE_PNG_URL], - signal: new AbortController().signal, - }), - ).toEqual({ kind: "none" }); - }); - - it("waits for physical rasterization settlement after a logical timeout", async () => { - vi.useFakeTimers(); - try { - let resolveOld!: (value: unknown) => void; - let executions = 0; - const { webContents } = makeWebContents({ - rasterize: () => { - executions += 1; - return executions === 1 - ? new Promise((resolve) => { - resolveOld = resolve; - }) - : Promise.resolve(PNG); - }, - }); - const input = { - webContents, - pageUrl: "https://example.com/page", - candidates: [SOURCE_PNG_URL], - signal: new AbortController().signal, - }; - const timedOut = captureFavicon(input); - await vi.advanceTimersByTimeAsync(1_001); - expect(await timedOut).toEqual({ kind: "timed-out" }); - - const newer = captureFavicon(input); - await Promise.resolve(); - expect(executions).toBe(1); - resolveOld(PNG); - expect(await newer).toEqual({ kind: "captured", dataUrl: PNG }); - expect(executions).toBe(2); - } finally { - vi.useRealTimers(); - } - }); - - it("ends candidate fallback after a rasterization timeout", async () => { - vi.useFakeTimers(); - try { - let resolveRasterization!: (value: unknown) => void; - const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents({ - rasterize: () => - new Promise((resolve) => { - resolveRasterization = resolve; - }), - }); - const capture = captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: ["https://example.com/first.png", "https://example.com/second.png"], - signal: new AbortController().signal, - }); - - await vi.advanceTimersByTimeAsync(1_001); - - expect(await capture).toEqual({ kind: "timed-out" }); - expect(fetch).toHaveBeenCalledOnce(); - expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce(); - resolveRasterization(PNG); - } finally { - vi.useRealTimers(); - } - }); - it("coalesces queued rasterizations so only the latest pending capture launches", async () => { let resolveFirst!: (value: unknown) => void; let executions = 0; diff --git a/apps/desktop/src/preview/GuestInput.test.ts b/apps/desktop/src/preview/GuestInput.test.ts index d607b217262f..6cb297ff81e9 100644 --- a/apps/desktop/src/preview/GuestInput.test.ts +++ b/apps/desktop/src/preview/GuestInput.test.ts @@ -19,6 +19,8 @@ const makeGuest = ({ focusedFrame: focused ? {} : null, insertText: vi.fn(async () => {}), sendInputEvent: vi.fn(), + reload: vi.fn(), + reloadIgnoringCache: vi.fn(), }; return { webContents, @@ -41,17 +43,6 @@ describe("preview guest input", () => { expect(guest.sendCommand).not.toHaveBeenCalledWith("Input.insertText", expect.anything()); }); - it("uses CDP input while the guest holds focus, where it already reaches the page", async () => { - const guest = makeGuest({ focused: true }); - await guest.send("Input.insertText", { text: "x" }); - await guest.send("Input.dispatchKeyEvent", { type: "keyUp", key: "a" }); - expect(guest.sendCommand.mock.calls.map(([method]) => method)).toEqual([ - "Input.insertText", - "Input.dispatchKeyEvent", - ]); - expect(guest.webContents.insertText).not.toHaveBeenCalled(); - }); - it("refuses input when a cross-site frame holds the unfocused page's focus", async () => { const guest = makeGuest({ focused: false, focusedDocument: false }); await expect(guest.send("Input.insertText", { text: "secret" })).rejects.toThrow( @@ -59,35 +50,6 @@ describe("preview guest input", () => { ); expect(guest.webContents.insertText).not.toHaveBeenCalled(); }); - - it("clears a field with Cmd+A and Delete while unfocused, never touching the clipboard", async () => { - const guest = makeGuest({ focused: false }); - await guest.send("Input.dispatchKeyEvent", { - type: "rawKeyDown", - key: "a", - modifiers: 4, - commands: ["selectAll", "paste"], - }); - await guest.send("Input.dispatchKeyEvent", { type: "rawKeyDown", key: "Delete" }); - expect(guest.webContents.sendInputEvent.mock.calls.map(([event]) => event)).toEqual([ - { type: "rawKeyDown", keyCode: "a", modifiers: ["meta"] }, - { type: "rawKeyDown", keyCode: "Delete", modifiers: [] }, - ]); - const scripts = guest.sendCommand.mock.calls.map(([, params]) => params?.expression ?? ""); - expect(scripts.some((script) => script.includes('["selectAll"]'))).toBe(true); - expect(scripts.some((script) => script.includes("paste"))).toBe(false); - }); - - it("does not run other page commands through the guest's direct input", async () => { - const guest = makeGuest({ focused: false }); - await guest.send("Input.dispatchMouseEvent", { type: "mousePressed", x: 1, y: 1 }); - expect(guest.sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { - type: "mousePressed", - x: 1, - y: 1, - }); - expect(guest.webContents.sendInputEvent).not.toHaveBeenCalled(); - }); }); describe("CDP key events as Electron input", () => { @@ -102,6 +64,7 @@ describe("CDP key events as Electron input", () => { ]); }); + // regression: #18556 agent typing in a preview tab landed in the app instead of the guest it("names arrows and function keys as Electron accelerators", () => { expect(toKeyboardInputEvents({ type: "keyUp", key: "ArrowLeft", modifiers: 3 })).toEqual([ { type: "keyUp", keyCode: "Left", modifiers: ["alt", "control"] }, diff --git a/apps/desktop/src/preview/GuestInput.ts b/apps/desktop/src/preview/GuestInput.ts index 773d6b79873b..35006dfcec08 100644 --- a/apps/desktop/src/preview/GuestInput.ts +++ b/apps/desktop/src/preview/GuestInput.ts @@ -115,13 +115,19 @@ const evaluate = async (debuggee: Electron.Debugger, expression: string) => { return reply.result?.value; }; -/** Sends one command to the guest's page, keeping key and text input inside it. */ +/** Sends one command to the guest's page, keeping reloads and input inside it. */ export const sendPageCommand = async ( webContents: Electron.WebContents, debuggee: Electron.Debugger, method: string, params: Record, ): Promise => { + // Electron's debugger routes Page.reload on a webview to its embedder window. + if (method === "Page.reload") { + if (params["ignoreCache"] === true) webContents.reloadIgnoringCache(); + else webContents.reload(); + return {}; + } if (!FOCUS_ROUTED_INPUT.has(method) || webContents.focusedFrame) { return debuggee.sendCommand(method, params); } diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index f059d672b1f2..6ded53bfaf1b 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,9 +1,9 @@ import { it as effectIt } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; import type { - DesktopPreviewRecordingFrame, DesktopPreviewRecordingInputEvent, + PreviewAnnotationPayload, + PreviewAnnotationRect, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { parseKeybindingShortcut } from "@t3tools/shared/keybindings"; @@ -32,11 +32,6 @@ import * as PreviewManager from "./Manager.ts"; import * as PreviewPasskeys from "./Passkeys.ts"; describe("fitPictureInPictureContentSize", () => { - it("preserves the PiP content area across aspect-ratio changes", () => { - expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 16 / 9)).toEqual([523, 294]); - expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 9 / 16)).toEqual([294, 523]); - }); - it("does not collapse toward the minimum size when orientation changes repeatedly", () => { const portrait = PreviewManager.fitPictureInPictureContentSize([523, 294], 9 / 16); const landscape = PreviewManager.fitPictureInPictureContentSize(portrait, 16 / 9); @@ -46,36 +41,6 @@ describe("fitPictureInPictureContentSize", () => { }); }); -describe("recordingFileExtension", () => { - it("derives the artifact extension from the recorder's actual mime type", () => { - expect(PreviewManager.recordingFileExtension("video/mp4;codecs=avc1.640028")).toBe("mp4"); - expect(PreviewManager.recordingFileExtension("video/webm;codecs=vp9")).toBe("webm"); - expect(PreviewManager.recordingFileExtension("video/x-matroska")).toBe("matroska"); - }); -}); - -describe("isPreviewRefreshShortcut", () => { - const input = (overrides: Partial = {}) => - ({ - type: "keyDown", - key: "r", - meta: true, - control: false, - shift: false, - alt: false, - ...overrides, - }) as Electron.Input; - - it("recognizes the platform refresh chord without matching modified variants", () => { - expect(PreviewManager.isPreviewRefreshShortcut(input())).toBe(true); - expect(PreviewManager.isPreviewRefreshShortcut(input({ meta: false, control: true }))).toBe( - true, - ); - expect(PreviewManager.isPreviewRefreshShortcut(input({ shift: true }))).toBe(false); - expect(PreviewManager.isPreviewRefreshShortcut(input({ type: "keyUp" }))).toBe(false); - }); -}); - describe("isPreviewEditingShortcut", () => { const input = (platform: NodeJS.Platform, key: string, overrides: Partial = {}) => ({ @@ -88,42 +53,6 @@ describe("isPreviewEditingShortcut", () => { ...overrides, }) as Electron.Input; - it.each(["darwin", "linux", "win32"] as const)( - "allows native editing chords on %s without allowing host shortcuts", - (platform) => { - for (const key of ["a", "c", "v", "x", "z", "V"]) { - expect(PreviewManager.isPreviewEditingShortcut(input(platform, key), platform)).toBe(true); - } - const redo = - platform === "win32" ? input(platform, "y") : input(platform, "z", { shift: true }); - expect(PreviewManager.isPreviewEditingShortcut(redo, platform)).toBe(true); - expect( - PreviewManager.isPreviewEditingShortcut( - input(platform, "v", { shift: true, alt: platform === "darwin" }), - platform, - ), - ).toBe(true); - - for (const key of ["k", ",", "w", "j", "q", "+", "=", "-", "0", "r", "F12"]) { - expect(PreviewManager.isPreviewEditingShortcut(input(platform, key), platform)).toBe(false); - } - for (const modifiers of [ - { meta: false, control: false }, - { meta: true, control: true }, - { meta: platform !== "darwin", control: platform === "darwin" }, - { alt: true }, - { shift: true, alt: platform !== "darwin" }, - ]) { - expect( - PreviewManager.isPreviewEditingShortcut(input(platform, "v", modifiers), platform), - ).toBe(false); - } - expect( - PreviewManager.isPreviewEditingShortcut(input(platform, "a", { shift: true }), platform), - ).toBe(false); - }, - ); - it("recognizes macOS Paste and Match Style when Option changes the key to a symbol", () => { const pasteAndMatchStyle = input("darwin", "◊", { code: "KeyV", alt: true, shift: true }); expect(PreviewManager.isPreviewEditingShortcut(pasteAndMatchStyle, "darwin")).toBe(true); @@ -159,15 +88,6 @@ describe("previewWindowOpenAction", () => { ).toBe("popup"); }); - it("opens target=_blank links as a new tab", () => { - expect(PreviewManager.previewWindowOpenAction(details({ disposition: "foreground-tab" }))).toBe( - "new-tab", - ); - expect(PreviewManager.previewWindowOpenAction(details({ disposition: "background-tab" }))).toBe( - "new-tab", - ); - }); - it("keeps a form POST with a body on the in-place path", () => { // A new tab can only reopen the URL as a GET, which would drop the body. const postBody = { data: [], contentType: "application/x-www-form-urlencoded" }; @@ -456,6 +376,7 @@ const makeSourcePng = (width = 1, height = 1): Buffer => { }; const makeFaviconWebContents = (options?: { + readonly changes?: ChangeSignal; readonly fetch?: (url: string, init?: RequestInit) => Promise; readonly id?: number; readonly rasterize?: (code: string) => Promise; @@ -466,16 +387,25 @@ const makeFaviconWebContents = (options?: { let currentUrl = options?.url ?? "http://localhost:3200/"; let destroyed = false; let loading = false; - const fetch = vi.fn( + const fetchImpl = options?.fetch ?? - (async () => - new Response(new Uint8Array(sourcePng), { - headers: { "content-type": "image/png" }, - })), - ); + (async () => + new Response(new Uint8Array(sourcePng), { + headers: { "content-type": "image/png" }, + })); + const fetch = vi.fn((url: string, init?: RequestInit) => { + const response = fetchImpl(url, init); + options?.changes?.notify(); + return response; + }); const executeJavaScriptInIsolatedWorld = vi.fn( - async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) => - options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : TEST_FAVICON, + (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) => { + const result = options?.rasterize + ? options.rasterize(scripts[0]?.code ?? "") + : Promise.resolve(TEST_FAVICON); + options?.changes?.notify(); + return result; + }, ); const reload = vi.fn(); const loadURL = vi.fn(async (url: string) => { @@ -540,10 +470,33 @@ const makeFaviconWebContents = (options?: { }; }; -const settle = function* (until: () => boolean) { - for (let attempt = 0; attempt < 30 && !until(); attempt++) { - yield* Effect.promise(() => Promise.resolve()); - } +// Electron mocks and state subscribers call `notify`, so a wait re-checks its +// predicate exactly when the state it reads changes. +const makeChangeSignal = () => { + const waiters = new Set<() => void>(); + return { + notify: () => { + for (const wake of waiters) wake(); + }, + until: (predicate: () => boolean) => + Effect.callback((resume) => { + if (predicate()) return resume(Effect.void); + const wake = () => { + if (!predicate()) return; + waiters.delete(wake); + resume(Effect.void); + }; + waiters.add(wake); + return Effect.sync(() => void waiters.delete(wake)); + }), + }; +}; +type ChangeSignal = ReturnType; + +// Favicon captures run detached from the Electron listener, so asserting that +// one published nothing can only give its promise chain a fixed number of turns. +const flushMicrotasks = function* () { + for (let turn = 0; turn < 30; turn++) yield* Effect.promise(() => Promise.resolve()); }; const makeTestPictureInPictureWindow = (loadURL: () => Promise = async () => undefined) => { @@ -603,154 +556,6 @@ describe("PreviewManager", () => { webviewSend.mockClear(); }); - effectIt.effect("opens a target=_blank link as a new tab without navigating the opener", () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - fromId.mockReturnValue(preview.webContents); - const opened: Array<{ tabId: string; url: string; background: boolean }> = []; - yield* manager.subscribeOpenLinks((event) => Effect.sync(() => void opened.push(event))); - yield* manager.createTab("tab_links"); - yield* manager.registerWebview("tab_links", 42); - const openHandler = (preview.webContents as Electron.WebContents).setWindowOpenHandler as - | ReturnType - | undefined; - const handler = openHandler?.mock.lastCall?.[0] as ( - details: Partial, - ) => { action: string }; - - expect( - handler({ url: "https://github.com/issues/1", disposition: "foreground-tab" }), - ).toEqual({ action: "deny" }); - expect(handler({ url: "about:blank", disposition: "new-window" })).toEqual({ - action: "deny", - }); - handler({ url: "https://github.com/issues/2", disposition: "background-tab" }); - yield* settle(() => opened.length === 2); - - expect(preview.loadURL).not.toHaveBeenCalled(); - expect(opened).toEqual([ - { tabId: "tab_links", url: "https://github.com/issues/1", background: false }, - { tabId: "tab_links", url: "https://github.com/issues/2", background: true }, - ]); - }), - ), - ); - - effectIt.effect("keeps preview shortcuts out of the host window", () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - const sendInputEvent = vi.fn(); - const hostWebContents = { sendInputEvent, isDestroyed: () => false }; - Object.assign(preview.webContents, { hostWebContents }); - fromId.mockReturnValue(preview.webContents); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: hostWebContents, - } as never); - yield* manager.createTab("tab_keys"); - yield* manager.registerWebview("tab_keys", 42); - - expect( - (preview.webContents as Electron.WebContents).setIgnoreMenuShortcuts, - ).toHaveBeenCalledWith(true); - const beforeInput = preview.listeners.get("before-input-event")!; - for (const control of [false, true]) { - for (const key of ["k", ",", "w", "j", "q", "+"]) { - for (const type of ["keyDown", "keyUp"]) { - const preventDefault = vi.fn(); - beforeInput( - { preventDefault } as never, - { type, key, meta: !control, control, shift: key === "j", alt: false } as never, - ); - yield* Effect.yieldNow; - expect(preventDefault).not.toHaveBeenCalled(); - expect( - (preview.webContents as Electron.WebContents).setIgnoreMenuShortcuts, - ).toHaveBeenLastCalledWith(true); - } - } - } - expect(sendInputEvent).not.toHaveBeenCalled(); - - const preventDefault = vi.fn(); - beforeInput( - { preventDefault } as never, - { - type: "keyDown", - key: "r", - meta: true, - control: false, - shift: false, - alt: false, - } as never, - ); - yield* Effect.yieldNow; - expect(preventDefault).toHaveBeenCalledOnce(); - expect(preview.reload).toHaveBeenCalledOnce(); - expect(sendInputEvent).not.toHaveBeenCalled(); - }), - ), - ); - - effectIt.effect.each([ - ["mod+shift+t", "view.reopenClosed"], - ["ctrl+alt+u", "sidebar.toggle"], - ] as const)( - "forwards only the configured command from the focused guest: %s", - ([chord, command]) => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - const send = vi.fn(); - Object.assign(preview.webContents, { - hostWebContents: { isDestroyed: () => false, send }, - }); - fromId.mockReturnValue(preview.webContents); - yield* manager.createTab("tab_reopen"); - yield* manager.registerWebview("tab_reopen", 42); - const shortcut = parseKeybindingShortcut(chord)!; - yield* manager.setForwardedShortcuts([{ command, shortcut }]); - const beforeInput = preview.listeners.get("before-input-event")!; - const input = { - type: "keyDown", - key: shortcut.key.toUpperCase(), - meta: shortcut.metaKey || shortcut.modKey, - control: shortcut.ctrlKey, - shift: shortcut.shiftKey, - alt: shortcut.altKey, - }; - const preventDefault = vi.fn(); - beforeInput({ preventDefault } as never, input as never); - expect(preventDefault).toHaveBeenCalledOnce(); - expect(send).toHaveBeenCalledExactlyOnceWith("desktop:menu-action", command); - for (const overrides of [ - { isAutoRepeat: true }, - { type: "keyUp" }, - { shift: !input.shift }, - { key: "x" }, - ]) { - preventDefault.mockClear(); - beforeInput({ preventDefault } as never, { ...input, ...overrides } as never); - expect(preventDefault).toHaveBeenCalledTimes("isAutoRepeat" in overrides ? 1 : 0); - expect(send).toHaveBeenCalledOnce(); - } - vi.mocked((preview.webContents as Electron.WebContents).isFocused).mockReturnValue(false); - preventDefault.mockClear(); - beforeInput({ preventDefault } as never, input as never); - expect(preventDefault).not.toHaveBeenCalled(); - expect(send).toHaveBeenCalledOnce(); - vi.mocked((preview.webContents as Electron.WebContents).isFocused).mockReturnValue(true); - yield* manager.setForwardedShortcuts([]); - beforeInput({ preventDefault } as never, input as never); - expect(preventDefault).not.toHaveBeenCalled(); - expect(send).toHaveBeenCalledOnce(); - }), - ), - ); - effectIt.effect.each([ ["linux", "ctrl+alt+[", "[", "BracketLeft", false], ["win32", "ctrl+alt+8", "[", "Digit8", false], @@ -795,52 +600,6 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("preserves focused browser editing in tabs and sign-in popups", () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - fromId.mockReturnValue(preview.webContents); - yield* manager.createTab("tab_editing"); - yield* manager.registerWebview("tab_editing", 42); - - const popup = makeFaviconWebContents({ id: 43 }); - preview.listeners.get("did-create-window")!({ webContents: popup.webContents } as never); - expect( - (popup.webContents as Electron.WebContents).setIgnoreMenuShortcuts, - ).toHaveBeenCalledWith(true); - - for (const browser of [preview, popup]) { - const contents = browser.webContents as Electron.WebContents; - const beforeInput = browser.listeners.get("before-input-event")!; - const preventDefault = vi.fn(); - const input = { - type: "keyDown", - key: "v", - meta: true, - control: false, - shift: false, - alt: false, - }; - beforeInput({ preventDefault } as never, input as never); - expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false); - beforeInput( - { preventDefault } as never, - { ...input, type: "keyUp", key: "Meta", meta: false } as never, - ); - expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false); - - beforeInput({ preventDefault } as never, { ...input, key: "w" } as never); - expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true); - - vi.mocked(contents.isFocused).mockReturnValue(false); - beforeInput({ preventDefault } as never, input as never); - expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true); - expect(preventDefault).not.toHaveBeenCalled(); - } - }), - ), - ); - effectIt.effect("keeps editing and forwarded shortcuts in whichever tab holds focus", () => withManager((manager) => Effect.gen(function* () { @@ -896,32 +655,6 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("rejects a destroyed webview during registration", () => - withManager((manager) => - Effect.gen(function* () { - const getType = vi.fn(() => "webview" as const); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => true, - getType, - } as never); - yield* manager.createTab("tab_destroyed_registration"); - - const exit = yield* Effect.exit(manager.registerWebview("tab_destroyed_registration", 42)); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ - _tag: "PreviewWebContentsNotFoundError", - tabId: "tab_destroyed_registration", - webContentsId: 42, - }); - } - expect(getType).not.toHaveBeenCalled(); - }), - ), - ); - effectIt.effect("isolates failed state listeners and continues delivery", () => { const loggedErrors: Array = []; const logger = Logger.make(({ message }) => { @@ -1034,36 +767,6 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("detaches a destroyed webview instead of navigating it", () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - fromId.mockReturnValue(preview.webContents); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_destroyed_navigation"); - yield* manager.registerWebview("tab_destroyed_navigation", 42); - yield* manager.setColorScheme("tab_destroyed_navigation", "dark"); - preview.setDestroyed(true); - - yield* manager.navigate("tab_destroyed_navigation", "https://example.com/"); - - expect(preview.loadURL).not.toHaveBeenCalled(); - expect(preview.reload).not.toHaveBeenCalled(); - expect(preview.off).toHaveBeenCalled(); - expect(preview.debuggerOff).toHaveBeenCalled(); - expect(states.at(-1)).toMatchObject({ - webContentsId: null, - navStatus: { kind: "Loading", url: "https://example.com/" }, - }); - }), - ), - ); - effectIt.effect("detaches through the pinned debugger after the webview is destroyed", () => withManager((manager) => Effect.gen(function* () { @@ -1170,46 +873,16 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("publishes a canonical favicon origin while the page is loading", () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents({ - url: `http://localhost:3200/${"x".repeat(3_000)}`, - }); - preview.setLoading(true); - fromId.mockReturnValue(preview.webContents); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_favicon_loading"); - yield* manager.registerWebview("tab_favicon_loading", 42); - - preview.listeners.get("page-favicon-updated")?.( - {} as never, - ["http://localhost:3200/favicon.png"] as never, - ); - yield* settle(() => states.at(-1)?.favicon !== undefined); - - expect(states.at(-1)?.favicon).toMatchObject({ - dataUrl: TEST_FAVICON, - pageUrl: "http://localhost:3200", - }); - expect(states.at(-1)?.favicon?.capturedAt).toEqual(expect.any(Number)); - }), - ), - ); - effectIt.effect("shares an identical in-flight event and lets a changed event win", () => withManager((manager) => Effect.gen(function* () { + const changes = makeChangeSignal(); let resolveFirst!: (response: Response) => void; const firstResponse = new Promise((resolve) => { resolveFirst = resolve; }); const preview = makeFaviconWebContents({ + changes, fetch: (url) => url.endsWith("first.png") ? firstResponse @@ -1224,6 +897,7 @@ describe("PreviewManager", () => { yield* manager.subscribeStateChanges((_tabId, state) => Effect.sync(() => { states.push(state); + changes.notify(); }), ); yield* manager.createTab("tab_favicon_latest"); @@ -1232,15 +906,15 @@ describe("PreviewManager", () => { const faviconUpdated = preview.listeners.get("page-favicon-updated")!; faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never); faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never); - yield* settle(() => preview.fetch.mock.calls.length === 1); + yield* changes.until(() => preview.fetch.mock.calls.length === 1); faviconUpdated({} as never, ["http://localhost:3200/second.png"] as never); - yield* settle(() => states.at(-1)?.favicon !== undefined); + yield* changes.until(() => states.at(-1)?.favicon !== undefined); resolveFirst( new Response(new Uint8Array(makeSourcePng()), { headers: { "content-type": "image/png" }, }), ); - yield* settle(() => false); + yield* flushMicrotasks(); expect(preview.fetch).toHaveBeenCalledTimes(2); expect(states.filter((state) => state.favicon !== undefined)).toHaveLength(1); @@ -1248,41 +922,13 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("allows an identical retry after an undecodable capture", () => - withManager((manager) => - Effect.gen(function* () { - let rasterizations = 0; - const preview = makeFaviconWebContents({ - rasterize: async () => (++rasterizations === 1 ? null : TEST_FAVICON), - }); - fromId.mockReturnValue(preview.webContents); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_favicon_retry"); - yield* manager.registerWebview("tab_favicon_retry", 42); - const faviconUpdated = preview.listeners.get("page-favicon-updated")!; - - faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never); - yield* settle(() => rasterizations === 1); - yield* settle(() => false); - faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never); - yield* settle(() => states.at(-1)?.favicon !== undefined); - - expect(rasterizations).toBe(2); - expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); - }), - ), - ); - effectIt.effect("does not publish a capture invalidated by navigation", () => withManager((manager) => Effect.gen(function* () { + const changes = makeChangeSignal(); let resolveFetch!: (response: Response) => void; const preview = makeFaviconWebContents({ + changes, fetch: () => new Promise((resolve) => { resolveFetch = resolve; @@ -1293,6 +939,7 @@ describe("PreviewManager", () => { yield* manager.subscribeStateChanges((_tabId, state) => Effect.sync(() => { states.push(state); + changes.notify(); }), ); yield* manager.createTab("tab_favicon_navigation"); @@ -1301,7 +948,7 @@ describe("PreviewManager", () => { {} as never, ["http://localhost:3200/favicon.png"] as never, ); - yield* settle(() => preview.fetch.mock.calls.length === 1); + yield* changes.until(() => preview.fetch.mock.calls.length === 1); preview.listeners.get("did-start-navigation")?.({ isMainFrame: true, isSameDocument: false, @@ -1312,1275 +959,395 @@ describe("PreviewManager", () => { headers: { "content-type": "image/png" }, }), ); - yield* settle(() => false); + yield* flushMicrotasks(); expect(states.some((state) => state.favicon !== undefined)).toBe(false); }), ), ); - effectIt.effect("retains a favicon when reloading the current URL without a new event", () => + effectIt.effect("ignores an old capture that completes after webview replacement", () => withManager((manager) => Effect.gen(function* () { - const preview = makeFaviconWebContents(); - fromId.mockReturnValue(preview.webContents); + const changes = makeChangeSignal(); + let resolveFetch!: (response: Response) => void; + const initial = makeFaviconWebContents({ + changes, + id: 42, + fetch: () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + }); + const replacement = makeFaviconWebContents({ changes, id: 43 }); + fromId.mockImplementation((id?: number) => + id === 42 ? initial.webContents : id === 43 ? replacement.webContents : null, + ); const states: PreviewManager.PreviewTabState[] = []; yield* manager.subscribeStateChanges((_tabId, state) => Effect.sync(() => { states.push(state); + changes.notify(); }), ); - yield* manager.createTab("tab_favicon_reload"); - yield* manager.registerWebview("tab_favicon_reload", 42); - preview.listeners.get("page-favicon-updated")?.( + yield* manager.createTab("tab_favicon_late_replace"); + yield* manager.registerWebview("tab_favicon_late_replace", 42); + initial.listeners.get("page-favicon-updated")?.( {} as never, ["http://localhost:3200/favicon.png"] as never, ); - yield* settle(() => states.at(-1)?.favicon !== undefined); - - yield* manager.navigate("tab_favicon_reload", "http://localhost:3200/"); + yield* changes.until(() => initial.fetch.mock.calls.length === 1); - expect(preview.reload).toHaveBeenCalledOnce(); - expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); - }), - ), - ); - - effectIt.effect("clears a published favicon after a confirmed cross-origin navigation", () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - fromId.mockReturnValue(preview.webContents); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); + yield* manager.registerWebview("tab_favicon_late_replace", 43); + resolveFetch( + new Response(new Uint8Array(makeSourcePng()), { + headers: { "content-type": "image/png" }, }), ); - yield* manager.createTab("tab_favicon_origin"); - yield* manager.registerWebview("tab_favicon_origin", 42); - preview.listeners.get("page-favicon-updated")?.( - {} as never, - ["http://localhost:3200/favicon.png"] as never, - ); - yield* settle(() => states.at(-1)?.favicon !== undefined); - - preview.setUrl("https://example.com/"); - preview.listeners.get("did-navigate")?.({} as never); - yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); + yield* flushMicrotasks(); - expect(states.at(-1)?.favicon).toBeUndefined(); + expect(states.at(-1)?.webContentsId).toBe(43); + expect( + states.some((state) => state.webContentsId === 43 && state.favicon !== undefined), + ).toBe(false); }), ), ); - effectIt.effect( - "retains the previous document icon across a failed cross-origin navigation", - () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - fromId.mockReturnValue(preview.webContents); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_favicon_failed_origin"); - yield* manager.registerWebview("tab_favicon_failed_origin", 42); - preview.listeners.get("page-favicon-updated")?.( - {} as never, - ["http://localhost:3200/favicon.png"] as never, - ); - yield* settle(() => states.at(-1)?.favicon !== undefined); - - preview.listeners.get("did-fail-load")?.( - {} as never, - -105 as never, - "Name not resolved" as never, - "https://unreachable.example/" as never, - true as never, - ); - yield* settle(() => states.at(-1)?.navStatus.kind === "LoadFailed"); - expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); - - preview.listeners.get("did-navigate")?.({} as never); - yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); - expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); - }), - ), - ); - - effectIt.effect("does not resurrect an icon after a confirmed about:blank document", () => + effectIt.effect("treats a reused WebContents id as a new attachment", () => withManager((manager) => Effect.gen(function* () { - const preview = makeFaviconWebContents(); - fromId.mockReturnValue(preview.webContents); + const changes = makeChangeSignal(); + const initial = makeFaviconWebContents({ changes, id: 42 }); + const replacement = makeFaviconWebContents({ changes, id: 42 }); + let active = initial.webContents; + fromId.mockImplementation(() => active); const states: PreviewManager.PreviewTabState[] = []; yield* manager.subscribeStateChanges((_tabId, state) => Effect.sync(() => { states.push(state); + changes.notify(); }), ); - yield* manager.createTab("tab_favicon_blank"); - yield* manager.registerWebview("tab_favicon_blank", 42); - preview.listeners.get("page-favicon-updated")?.( + yield* manager.createTab("tab_favicon_reused_id"); + yield* manager.registerWebview("tab_favicon_reused_id", 42); + initial.listeners.get("page-favicon-updated")?.( {} as never, ["http://localhost:3200/favicon.png"] as never, ); - yield* settle(() => states.at(-1)?.favicon !== undefined); + yield* changes.until(() => states.at(-1)?.favicon !== undefined); - preview.setUrl("about:blank"); - preview.listeners.get("did-navigate")?.({} as never); - yield* settle(() => states.at(-1)?.navStatus.kind === "Idle"); - expect(states.at(-1)?.favicon).toBeUndefined(); + active = replacement.webContents; + yield* manager.registerWebview("tab_favicon_reused_id", 42); - preview.setUrl("http://localhost:3200/"); - preview.listeners.get("did-navigate")?.({} as never); - yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); expect(states.at(-1)?.favicon).toBeUndefined(); + expect(initial.off).toHaveBeenCalled(); + expect(replacement.listeners.has("page-favicon-updated")).toBe(true); }), ), ); - effectIt.effect("clears a published favicon when a replacement webview attaches", () => - withManager((manager) => - Effect.gen(function* () { - const initial = makeFaviconWebContents({ id: 42 }); - const replacement = makeFaviconWebContents({ id: 43 }); - fromId.mockImplementation((id?: number) => { - if (id === 42) return initial.webContents; - if (id === 43) return replacement.webContents; - return null; - }); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_favicon_replace"); - yield* manager.registerWebview("tab_favicon_replace", 42); - initial.listeners.get("page-favicon-updated")?.( - {} as never, - ["http://localhost:3200/favicon.png"] as never, - ); - yield* settle(() => states.at(-1)?.favicon !== undefined); - - yield* manager.registerWebview("tab_favicon_replace", 43); - - expect(states.at(-1)?.webContentsId).toBe(43); - expect(states.at(-1)?.favicon).toBeUndefined(); - }), - ), - ); - - effectIt.effect("ignores an old capture that completes after webview replacement", () => - withManager((manager) => - Effect.gen(function* () { - let resolveFetch!: (response: Response) => void; - const initial = makeFaviconWebContents({ - id: 42, - fetch: () => - new Promise((resolve) => { - resolveFetch = resolve; - }), - }); - const replacement = makeFaviconWebContents({ id: 43 }); - fromId.mockImplementation((id?: number) => - id === 42 ? initial.webContents : id === 43 ? replacement.webContents : null, - ); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_favicon_late_replace"); - yield* manager.registerWebview("tab_favicon_late_replace", 42); - initial.listeners.get("page-favicon-updated")?.( - {} as never, - ["http://localhost:3200/favicon.png"] as never, - ); - yield* settle(() => initial.fetch.mock.calls.length === 1); - - yield* manager.registerWebview("tab_favicon_late_replace", 43); - resolveFetch( - new Response(new Uint8Array(makeSourcePng()), { - headers: { "content-type": "image/png" }, - }), - ); - yield* settle(() => false); - - expect(states.at(-1)?.webContentsId).toBe(43); - expect( - states.some((state) => state.webContentsId === 43 && state.favicon !== undefined), - ).toBe(false); - }), - ), - ); - - effectIt.effect("treats a reused WebContents id as a new attachment", () => - withManager((manager) => - Effect.gen(function* () { - const initial = makeFaviconWebContents({ id: 42 }); - const replacement = makeFaviconWebContents({ id: 42 }); - let active = initial.webContents; - fromId.mockImplementation(() => active); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_favicon_reused_id"); - yield* manager.registerWebview("tab_favicon_reused_id", 42); - initial.listeners.get("page-favicon-updated")?.( - {} as never, - ["http://localhost:3200/favicon.png"] as never, - ); - yield* settle(() => states.at(-1)?.favicon !== undefined); - - active = replacement.webContents; - yield* manager.registerWebview("tab_favicon_reused_id", 42); - - expect(states.at(-1)?.favicon).toBeUndefined(); - expect(initial.off).toHaveBeenCalled(); - expect(replacement.listeners.has("page-favicon-updated")).toBe(true); - }), - ), - ); - - effectIt.effect("preserves a favicon when the active attachment registers again", () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - fromId.mockReturnValue(preview.webContents); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_favicon_reregister"); - yield* manager.registerWebview("tab_favicon_reregister", 42); - preview.listeners.get("page-favicon-updated")?.( - {} as never, - ["http://localhost:3200/favicon.png"] as never, - ); - yield* settle(() => states.at(-1)?.favicon !== undefined); - - yield* manager.registerWebview("tab_favicon_reregister", 42); - - expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); - }), - ), - ); - - // The guest reports whatever zoom level Chromium handed it from the app - // window, so the tab's own zoom is the source of truth in both directions: - // asserted onto every guest, never read back off one. - effectIt.effect("keeps the tab's own zoom instead of the guest's reported zoom", () => - withManager((manager) => - Effect.gen(function* () { - let effectiveZoom = 0.9; - let zoomReadable = true; - let url = "https://example.com"; - const listeners = new Map void>(); - const setZoomFactor = vi.fn(); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => url, - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => { - if (!zoomReadable) throw new Error("zoom unavailable"); - return effectiveZoom; - }, - setZoomFactor, - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { - listeners.set(event, listener); - }), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never); - const states: PreviewManager.PreviewTabState[] = []; - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_zoom"); - yield* manager.registerWebview("tab_zoom", 42); - - expect(states.at(-1)?.zoomFactor).toBe(1); - expect(setZoomFactor).toHaveBeenCalledWith(1); - - effectiveZoom = 0.8; - url = "https://example.com/after-app-zoom"; - listeners.get("did-navigate")?.(); - yield* Effect.yieldNow; - - expect(states.at(-1)?.navStatus).toEqual({ - kind: "Success", - url, - title: "Example", - }); - expect(states.at(-1)?.zoomFactor).toBe(1); - - yield* manager.zoomIn("tab_zoom"); - expect(setZoomFactor).toHaveBeenCalledWith(1.1); - expect(states.at(-1)?.zoomFactor).toBe(1.1); - - zoomReadable = false; - listeners.get("did-navigate")?.(); - yield* Effect.yieldNow; - - expect(states.at(-1)?.zoomFactor).toBe(1.1); - - const replacementSetZoomFactor = vi.fn(); - fromId.mockReturnValue({ - id: 43, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => url, - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor: replacementSetZoomFactor, - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never); - - yield* manager.registerWebview("tab_zoom", 43); - - expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.1); - expect(states.at(-1)?.zoomFactor).toBe(1.1); - }), - ), - ); - - // Zooming the app UI pushes the window's zoom level onto every guest, so the - // preview has to be put back at the zoom the user gave it. - effectIt.effect("re-applies each tab's own zoom when the app window zooms", () => - withManager((manager) => - Effect.gen(function* () { - const setZoomFactor = vi.fn(); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor, - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never); - - yield* manager.createTab("tab_reapply"); - yield* manager.registerWebview("tab_reapply", 42); - yield* manager.zoomIn("tab_reapply"); - setZoomFactor.mockClear(); - - yield* manager.reapplyZoom(); - - expect(setZoomFactor).toHaveBeenCalledTimes(1); - expect(setZoomFactor).toHaveBeenCalledWith(1.1); - }), - ), - ); - - effectIt.effect("re-asserts the tab's zoom when the active guest registers again", () => - withManager((manager) => - Effect.gen(function* () { - const setZoomFactor = vi.fn(); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor, - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - - yield* manager.createTab("tab_reregister_zoom"); - yield* manager.registerWebview("tab_reregister_zoom", 42); - yield* manager.zoomIn("tab_reregister_zoom"); - setZoomFactor.mockClear(); - const publishedBefore = states.length; - - yield* manager.registerWebview("tab_reregister_zoom", 42); - - expect(setZoomFactor).toHaveBeenCalledWith(1.1); - expect(states.length).toBe(publishedBefore); - expect(states.at(-1)?.zoomFactor).toBe(1.1); - }), - ), - ); - - effectIt.effect("re-applies opaque base and color scheme across webview swaps", () => - withManager((manager) => - Effect.gen(function* () { - const makeWebContents = (id: number) => { - const sendCommand = vi.fn(async () => undefined); - return { - sendCommand, - wc: { - id, - isDestroyed: () => false, - isDevToolsOpened: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand, - on: vi.fn(), - off: vi.fn(), - }, - } as never, - }; - }; - const first = makeWebContents(42); - fromId.mockReturnValue(first.wc); - const states: PreviewManager.PreviewTabState[] = []; - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_scheme"); - yield* manager.registerWebview("tab_scheme", 42); - yield* Effect.yieldNow; - - // Guests start with a transparent base; dark-scheme pages need an opaque - // one or Chromium skips their dark canvas. - const opaqueBase = { - color: { r: 255, g: 255, b: 255, a: 1 }, - }; - expect(first.sendCommand).toHaveBeenCalledWith( - "Emulation.setDefaultBackgroundColorOverride", - opaqueBase, - ); - - yield* manager.setColorScheme("tab_scheme", "dark"); - - expect(first.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { - features: [{ name: "prefers-color-scheme", value: "dark" }], - }); - expect(states.at(-1)?.colorScheme).toBe("dark"); - - const replacement = makeWebContents(43); - fromId.mockReturnValue(replacement.wc); - yield* manager.registerWebview("tab_scheme", 43); - yield* Effect.yieldNow; - - expect(replacement.sendCommand).toHaveBeenCalledWith( - "Emulation.setDefaultBackgroundColorOverride", - opaqueBase, - ); - expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { - features: [{ name: "prefers-color-scheme", value: "dark" }], - }); - expect(states.at(-1)?.colorScheme).toBe("dark"); - - yield* manager.setColorScheme("tab_scheme", "system"); - - expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { - features: [{ name: "prefers-color-scheme", value: "" }], - }); - expect(states.at(-1)?.colorScheme).toBe("system"); - }), - ), - ); - - const makeAttachingGuest = (id: number) => { - const listeners = new Map void>(); - const attach = vi.fn(); - const detach = vi.fn(); - const sendCommand = vi.fn<(method: string, params?: unknown) => Promise>( - async () => undefined, - ); - const detachListeners = new Set<() => void>(); - let chromiumDetaches = 0; - let destroyed = false; - const wc = { - id, - isDestroyed: () => destroyed, - isDevToolsOpened: () => { - // Electron throws from native methods once a WebContents is destroyed. - if (destroyed) throw new TypeError("Object has been destroyed"); - return false; - }, - getType: () => "webview", - getURL: () => "http://localhost:5173/README.md", - getTitle: () => "README.md", - isLoadingMainFrame: () => true, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - once: (event: string, listener: () => void) => { - listeners.set(event, listener); - }, - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => attach.mock.calls.length > detach.mock.calls.length + chromiumDetaches, - attach, - detach, - sendCommand, - on: (event: string, listener: () => void) => { - if (event === "detach") detachListeners.add(listener); - }, - off: (event: string, listener: () => void) => { - if (event === "detach") detachListeners.delete(listener); - }, - }, - }; - /** Chromium dropping the debugger without a detach() call. */ - const chromiumDetach = () => { - chromiumDetaches += 1; - // Snapshot like an EventEmitter, so a listener added during emit waits for the next one. - for (const listener of Array.from(detachListeners)) listener(); - }; - return { - wc: wc as unknown as Electron.WebContents, - attach, - detach, - sendCommand, - chromiumDetach, - destroy: () => { - destroyed = true; - listeners.get("destroyed")?.(); - }, - }; - }; - - effectIt.effect("sets the opaque base as soon as a guest attaches", () => - withManager((manager) => - Effect.gen(function* () { - const claimed = makeAttachingGuest(44); - fromId.mockReturnValue(claimed.wc); - yield* manager.prepareWebview(claimed.wc); - expect(claimed.attach).toHaveBeenCalledTimes(1); - expect(claimed.sendCommand.mock.calls[0]).toEqual([ - "Emulation.setDefaultBackgroundColorOverride", - { color: { r: 255, g: 255, b: 255, a: 1 } }, - ]); - - yield* manager.createTab("tab_early"); - yield* manager.registerWebview("tab_early", 44); - yield* manager.setColorScheme("tab_early", "dark"); - expect(claimed.attach).toHaveBeenCalledTimes(1); - expect(claimed.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { - features: [{ name: "prefers-color-scheme", value: "dark" }], - }); - - const unclaimed = makeAttachingGuest(45); - yield* manager.prepareWebview(unclaimed.wc); - expect(unclaimed.attach).toHaveBeenCalledTimes(1); - unclaimed.destroy(); - yield* Effect.yieldNow; - expect(unclaimed.detach).toHaveBeenCalledTimes(1); - expect(claimed.detach).not.toHaveBeenCalled(); - }), - ), - ); - - effectIt.effect("reattaches a debugger that Chromium dropped on its own", () => - withManager((manager) => - Effect.gen(function* () { - const guest = makeAttachingGuest(48); - fromId.mockReturnValue(guest.wc); - yield* manager.createTab("tab_detached"); - yield* manager.registerWebview("tab_detached", 48); - yield* Effect.yieldNow; - expect(guest.attach).toHaveBeenCalledTimes(1); - - guest.chromiumDetach(); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - expect(guest.attach).toHaveBeenCalledTimes(2); - }), - ), - ); - - effectIt.effect("skips a guest destroyed while another guest's session opens", () => - withManager((manager) => - Effect.gen(function* () { - const slow = makeAttachingGuest(46); - let releaseSlow = () => {}; - const slowCommand = new Promise((resolve) => { - releaseSlow = resolve; - }); - slow.sendCommand.mockImplementation(() => slowCommand); - const queued = makeAttachingGuest(47); - - const slowFiber = yield* manager - .prepareWebview(slow.wc) - .pipe(Effect.forkChild({ startImmediately: true })); - const queuedFiber = yield* manager - .prepareWebview(queued.wc) - .pipe(Effect.forkChild({ startImmediately: true })); - queued.destroy(); - releaseSlow(); - - expect(Exit.isSuccess(yield* Fiber.await(slowFiber))).toBe(true); - expect(Exit.isSuccess(yield* Fiber.await(queuedFiber))).toBe(true); - expect(slow.attach).toHaveBeenCalledTimes(1); - expect(queued.attach).not.toHaveBeenCalled(); - }), - ), - ); - - const makeAudioWebContents = (id: number) => { - const listeners = new Map void>(); - const setAudioMuted = vi.fn(); - let audible = false; - let audibleAfterFirstRead = false; - let audibleReads = 0; - return { - setAudioMuted, - emitAudioState: (next: boolean) => { - audible = next; - listeners.get("audio-state-changed")?.({ audible: next } as never); + const makeAttachingGuest = (id: number) => { + const listeners = new Map void>(); + const attach = vi.fn(); + const detach = vi.fn(); + const sendCommand = vi.fn<(method: string, params?: unknown) => Promise>( + async () => undefined, + ); + const detachListeners = new Set<() => void>(); + let chromiumDetaches = 0; + let destroyed = false; + const wc = { + id, + isDestroyed: () => destroyed, + isDevToolsOpened: () => { + // Electron throws from native methods once a WebContents is destroyed. + if (destroyed) throw new TypeError("Object has been destroyed"); + return false; }, - /** - * Starts playing between the attach-time read and the post-attach - * reconcile, without a delivered event — the window in which - * audio-state-changed fires against a guest the tab does not own yet. - */ - startPlayingAfterFirstRead: () => { - audibleAfterFirstRead = true; + getType: () => "webview", + getURL: () => "http://localhost:5173/README.md", + getTitle: () => "README.md", + isLoadingMainFrame: () => true, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + once: (event: string, listener: () => void) => { + listeners.set(event, listener); }, - wc: { - id, - isDestroyed: () => false, - isDevToolsOpened: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted, - isCurrentlyAudible: () => { - audibleReads += 1; - if (audibleAfterFirstRead && audibleReads > 1) return true; - return audible; + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => attach.mock.calls.length > detach.mock.calls.length + chromiumDetaches, + attach, + detach, + sendCommand, + on: (event: string, listener: () => void) => { + if (event === "detach") detachListeners.add(listener); }, - loadURL: vi.fn(async () => undefined), - on: vi.fn((event: string, listener: (...args: never[]) => void) => { - listeners.set(event, listener); - }), - off: vi.fn((event: string) => { - listeners.delete(event); - }), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), + off: (event: string, listener: () => void) => { + if (event === "detach") detachListeners.delete(listener); }, - } as never, + }, }; - }; - - effectIt.effect("mutes the guest and re-applies the mute across webview swaps", () => - withManager((manager) => - Effect.gen(function* () { - const first = makeAudioWebContents(42); - fromId.mockReturnValue(first.wc); - const states: PreviewManager.PreviewTabState[] = []; - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_audio"); - yield* manager.registerWebview("tab_audio", 42); - yield* Effect.yieldNow; - - expect(states.at(-1)?.audioMuted).toBe(false); - - yield* manager.setAudioMuted("tab_audio", true); - - expect(first.setAudioMuted).toHaveBeenCalledWith(true); - expect(states.at(-1)?.audioMuted).toBe(true); - - const replacement = makeAudioWebContents(43); - fromId.mockReturnValue(replacement.wc); - yield* manager.registerWebview("tab_audio", 43); - yield* Effect.yieldNow; - - expect(replacement.setAudioMuted).toHaveBeenCalledWith(true); - expect(states.at(-1)?.audioMuted).toBe(true); - - yield* manager.setAudioMuted("tab_audio", false); - - expect(replacement.setAudioMuted).toHaveBeenLastCalledWith(false); - expect(states.at(-1)?.audioMuted).toBe(false); - }), - ), - ); - - effectIt.effect("fails and rolls back when the guest refuses a mute", () => - withManager((manager) => - Effect.gen(function* () { - const guest = makeAudioWebContents(42); - fromId.mockReturnValue(guest.wc); - const states: PreviewManager.PreviewTabState[] = []; - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_audio_fail"); - yield* manager.registerWebview("tab_audio_fail", 42); - yield* Effect.yieldNow; - - guest.setAudioMuted.mockImplementationOnce(() => { - throw new Error("guest refused"); - }); - const exit = yield* manager.setAudioMuted("tab_audio_fail", true).pipe(Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - expect(states.at(-1)?.audioMuted).toBe(false); - }), - ), - ); - - effectIt.effect("still registers a guest that refuses the mute reassert", () => - withManager((manager) => - Effect.gen(function* () { - const first = makeAudioWebContents(42); - fromId.mockReturnValue(first.wc); - yield* manager.createTab("tab_audio_attach_fail"); - yield* manager.registerWebview("tab_audio_attach_fail", 42); - yield* Effect.yieldNow; - yield* manager.setAudioMuted("tab_audio_attach_fail", true); - - const replacement = makeAudioWebContents(43); - replacement.setAudioMuted.mockImplementationOnce(() => undefined); - replacement.setAudioMuted.mockImplementationOnce(() => { - throw new Error("guest went away"); - }); - fromId.mockReturnValue(replacement.wc); - - const exit = yield* manager.registerWebview("tab_audio_attach_fail", 43).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - }), - ), - ); - - effectIt.effect("reconciles audibility that changed while the guest attached", () => - withManager((manager) => - Effect.gen(function* () { - const guest = makeAudioWebContents(42); - guest.startPlayingAfterFirstRead(); - fromId.mockReturnValue(guest.wc); - const states: PreviewManager.PreviewTabState[] = []; - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_audio_window"); - yield* manager.registerWebview("tab_audio_window", 42); - yield* Effect.yieldNow; - - // audio-state-changed for this transition was dropped: it fired before - // the tab owned the guest. Without a post-attach reconcile the icon - // stays wrong until the next real transition, which may never come. - expect(states.at(-1)?.audible).toBe(true); - }), - ), - ); - - effectIt.effect("publishes audibility transitions and drops repeats", () => - withManager((manager) => - Effect.gen(function* () { - const guest = makeAudioWebContents(42); - fromId.mockReturnValue(guest.wc); - const states: PreviewManager.PreviewTabState[] = []; - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_audible"); - yield* manager.registerWebview("tab_audible", 42); - yield* Effect.yieldNow; - - expect(states.at(-1)?.audible).toBe(false); - - guest.emitAudioState(true); - yield* Effect.yieldNow; - expect(states.at(-1)?.audible).toBe(true); - - // Chromium re-emits per media element; only real transitions publish. - const publishedAfterFirst = states.length; - guest.emitAudioState(true); - yield* Effect.yieldNow; - expect(states.length).toBe(publishedAfterFirst); - - guest.emitAudioState(false); - yield* Effect.yieldNow; - expect(states.at(-1)?.audible).toBe(false); - expect(states.length).toBeGreaterThan(publishedAfterFirst); - }), - ), - ); - - effectIt.effect("ignores audio state from a replaced guest", () => - withManager((manager) => - Effect.gen(function* () { - const first = makeAudioWebContents(42); - fromId.mockReturnValue(first.wc); - const states: PreviewManager.PreviewTabState[] = []; - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_audio_stale"); - yield* manager.registerWebview("tab_audio_stale", 42); - yield* Effect.yieldNow; - - const replacement = makeAudioWebContents(43); - fromId.mockReturnValue(replacement.wc); - yield* manager.registerWebview("tab_audio_stale", 43); - yield* Effect.yieldNow; - - const publishedBefore = states.length; - first.emitAudioState(true); - yield* Effect.yieldNow; - - expect(states.length).toBe(publishedBefore); - expect(states.at(-1)?.audible).toBe(false); - }), - ), - ); - - effectIt.effect("carries mute and audibility across navigation", () => - withManager((manager) => - Effect.gen(function* () { - const guest = makeAudioWebContents(42); - fromId.mockReturnValue(guest.wc); - const states: PreviewManager.PreviewTabState[] = []; - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_audio_nav"); - yield* manager.registerWebview("tab_audio_nav", 42); - yield* Effect.yieldNow; + /** Chromium dropping the debugger without a detach() call. */ + const chromiumDetach = () => { + chromiumDetaches += 1; + // Snapshot like an EventEmitter, so a listener added during emit waits for the next one. + for (const listener of Array.from(detachListeners)) listener(); + }; + return { + wc: wc as unknown as Electron.WebContents, + attach, + detach, + sendCommand, + chromiumDetach, + destroy: () => { + destroyed = true; + listeners.get("destroyed")?.(); + }, + }; + }; - yield* manager.setAudioMuted("tab_audio_nav", true); - guest.emitAudioState(true); + effectIt.effect("reattaches a debugger that Chromium dropped on its own", () => + withManager((manager) => + Effect.gen(function* () { + const guest = makeAttachingGuest(48); + fromId.mockReturnValue(guest.wc); + yield* manager.createTab("tab_detached"); + yield* manager.registerWebview("tab_detached", 48); yield* Effect.yieldNow; - expect(states.at(-1)?.audible).toBe(true); + expect(guest.attach).toHaveBeenCalledTimes(1); - yield* manager.navigate("tab_audio_nav", "https://example.com/next"); + guest.chromiumDetach(); yield* Effect.yieldNow; - - expect(states.at(-1)?.audioMuted).toBe(true); - expect(states.at(-1)?.audible).toBe(true); - - guest.emitAudioState(false); yield* Effect.yieldNow; - expect(states.at(-1)?.audible).toBe(false); + expect(guest.attach).toHaveBeenCalledTimes(2); }), ), ); - effectIt.effect("blocks late webview and capture starts during tab close", () => + effectIt.effect("skips a guest destroyed while another guest's session opens", () => withManager((manager) => Effect.gen(function* () { - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("close-race-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - const firstWebContents = makeTestPreviewWebContents(capturePage, 42); - const replacementWebContents = makeTestPreviewWebContents(capturePage, 43); - const replacementListenerSpies = replacementWebContents as unknown as { - readonly on: ReturnType; - readonly off: ReturnType; - readonly ipc: { readonly off: ReturnType }; - }; - fromId.mockImplementation((id) => { - if (id === 42) return firstWebContents; - if (id === 43) return replacementWebContents; - return null; - }); - const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; + const slow = makeAttachingGuest(46); + let releaseSlow = () => {}; + const slowCommand = new Promise((resolve) => { + releaseSlow = resolve; }); + slow.sendCommand.mockImplementation(() => slowCommand); + const queued = makeAttachingGuest(47); - yield* manager.createTab("tab_close_register_race"); - yield* manager.registerWebview("tab_close_register_race", 42); - yield* manager.openPictureInPicture("tab_close_register_race"); - - const closeCleanupPaused = yield* Deferred.make(); - const continueCloseCleanup = yield* Deferred.make(); - yield* manager.subscribeStateChanges((_tabId, state) => - !state.pictureInPicture && state.webContentsId === 42 - ? Deferred.succeed(closeCleanupPaused, undefined).pipe( - Effect.andThen(Deferred.await(continueCloseCleanup)), - ) - : Effect.void, - ); - - const closeFiber = yield* manager - .closeTab("tab_close_register_race") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(closeCleanupPaused); - const recreateFiber = yield* manager - .createTab("tab_close_register_race") + const slowFiber = yield* manager + .prepareWebview(slow.wc) .pipe(Effect.forkChild({ startImmediately: true })); - const registrationFiber = yield* manager - .registerWebview("tab_close_register_race", 43) + const queuedFiber = yield* manager + .prepareWebview(queued.wc) .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.yieldNow; - expect(replacementListenerSpies.on).not.toHaveBeenCalled(); - yield* manager.closeTab("tab_close_register_race"); - const recordingExit = yield* Effect.exit(manager.startRecording("tab_close_register_race")); - yield* Deferred.succeed(continueCloseCleanup, undefined); - yield* Fiber.join(closeFiber); - const recreated = yield* Fiber.join(recreateFiber); - const registrationExit = yield* Fiber.await(registrationFiber); + queued.destroy(); + releaseSlow(); - for (const exit of [registrationExit, recordingExit] as ReadonlyArray< - Exit.Exit - >) { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isSuccess(exit)) continue; - expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ - _tag: "PreviewTabNotFoundError", - tabId: "tab_close_register_race", - }); - } - expect(replacementListenerSpies.on).not.toHaveBeenCalled(); - expect(replacementListenerSpies.off).not.toHaveBeenCalled(); - expect(replacementListenerSpies.ipc.off).not.toHaveBeenCalled(); - expect(capturePage).toHaveBeenCalledOnce(); - expect(recreated.webContentsId).toBeNull(); + expect(Exit.isSuccess(yield* Fiber.await(slowFiber))).toBe(true); + expect(Exit.isSuccess(yield* Fiber.await(queuedFiber))).toBe(true); + expect(slow.attach).toHaveBeenCalledTimes(1); + expect(queued.attach).not.toHaveBeenCalled(); }), ), ); - effectIt.effect("keeps a main-frame load failure visible until a retry starts", () => + const makeAudioWebContents = (id: number) => { + const listeners = new Map void>(); + const setAudioMuted = vi.fn(); + let audible = false; + let audibleAfterFirstRead = false; + let audibleReads = 0; + return { + setAudioMuted, + emitAudioState: (next: boolean) => { + audible = next; + listeners.get("audio-state-changed")?.({ audible: next } as never); + }, + /** + * Starts playing between the attach-time read and the post-attach + * reconcile, without a delivered event — the window in which + * audio-state-changed fires against a guest the tab does not own yet. + */ + startPlayingAfterFirstRead: () => { + audibleAfterFirstRead = true; + }, + wc: { + id, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoadingMainFrame: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted, + isCurrentlyAudible: () => { + audibleReads += 1; + if (audibleAfterFirstRead && audibleReads > 1) return true; + return audible; + }, + loadURL: vi.fn(async () => undefined), + on: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), + off: vi.fn((event: string) => { + listeners.delete(event); + }), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never, + }; + }; + + effectIt.effect("reconciles audibility that changed while the guest attached", () => withManager((manager) => Effect.gen(function* () { - const url = "http://localhost:5733/"; - let loading = false; - const listeners = new Map void>(); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => url, - getTitle: () => "localhost:5733", - isLoadingMainFrame: () => loading, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { - listeners.set(event, listener); - }), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never); - const statuses: PreviewManager.PreviewNavStatus[] = []; + const guest = makeAudioWebContents(42); + guest.startPlayingAfterFirstRead(); + fromId.mockReturnValue(guest.wc); + const states: PreviewManager.PreviewTabState[] = []; yield* manager.subscribeStateChanges((_tabId, state) => Effect.sync(() => { - statuses.push(state.navStatus); + states.push(state); }), ); - yield* manager.createTab("tab_failed"); - yield* manager.registerWebview("tab_failed", 42); - - listeners.get("did-fail-load")?.( - {}, - -105, - "ERR_NAME_NOT_RESOLVED", - "https://missing-frame.example/", - false, - ); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Success"); - - loading = true; - listeners.get("did-start-loading")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Loading"); - - loading = false; - listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true); - listeners.get("did-stop-loading")?.(); - listeners.get("page-title-updated")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)).toEqual({ - kind: "LoadFailed", - url, - title: "localhost:5733", - code: -102, - description: "ERR_CONNECTION_REFUSED", - }); - - loading = true; - listeners.get("did-start-loading")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Loading"); - - loading = false; - listeners.get("did-stop-loading")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Success"); - - listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("LoadFailed"); - - listeners.get("did-navigate")?.(); + yield* manager.createTab("tab_audio_window"); + yield* manager.registerWebview("tab_audio_window", 42); yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Success"); - }), - ), - ); - - effectIt.effect("ignores a late cross-origin iframe load once the page has loaded", () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - Object.assign(preview.webContents, { isLoading: () => true }); - fromId.mockReturnValue(preview.webContents); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => void states.push(state)), - ); - yield* manager.createTab("tab_iframe"); - yield* manager.registerWebview("tab_iframe", 42); - preview.listeners.get("did-start-loading")?.(); - preview.listeners.get("did-stop-loading")?.(); - yield* settle(() => states.length > 2); - expect(states.at(-1)?.navStatus.kind).toBe("Success"); + // audio-state-changed for this transition was dropped: it fired before + // the tab owned the guest. Without a post-attach reconcile the icon + // stays wrong until the next real transition, which may never come. + expect(states.at(-1)?.audible).toBe(true); }), ), ); - effectIt.effect("captures a PNG screenshot into browser artifacts", () => + effectIt.effect("ignores audio state from a replaced guest", () => withManager((manager) => Effect.gen(function* () { - const png = Buffer.from("preview-png"); - const capturePage = vi.fn(async () => ({ toPNG: () => png })); - const listeners = new Map void>(); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com:8443/path?query=value", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn((event: string, listener: (...args: never[]) => void) => { - listeners.set(event, listener); - }), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - capturePage, - } as never); - - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); + const first = makeAudioWebContents(42); + fromId.mockReturnValue(first.wc); + const states: PreviewManager.PreviewTabState[] = []; - expect(webviewSend).toHaveBeenCalledWith( - "preview:annotation-theme", - expect.objectContaining({ - colorScheme: "light", - primary: "oklch(0.488 0.217 264)", + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); }), ); + yield* manager.createTab("tab_audio_stale"); + yield* manager.registerWebview("tab_audio_stale", 42); + yield* Effect.yieldNow; - const artifact = yield* manager.captureScreenshot("tab_1"); + const replacement = makeAudioWebContents(43); + fromId.mockReturnValue(replacement.wc); + yield* manager.registerWebview("tab_audio_stale", 43); + yield* Effect.yieldNow; - expect(capturePage).toHaveBeenCalledOnce(); - expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts"); - expect(writeFile).toHaveBeenCalledWith(artifact.path, png); - expect(artifact).toMatchObject({ - tabId: "tab_1", - mimeType: "image/png", - sizeBytes: png.byteLength, + const publishedBefore = states.length; + first.emitAudioState(true); + yield* Effect.yieldNow; + + expect(states.length).toBe(publishedBefore); + expect(states.at(-1)?.audible).toBe(false); + }), + ), + ); + + effectIt.effect("blocks late webview and capture starts during tab close", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("close-race-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const firstWebContents = makeTestPreviewWebContents(capturePage, 42); + const replacementWebContents = makeTestPreviewWebContents(capturePage, 43); + const replacementListenerSpies = replacementWebContents as unknown as { + readonly on: ReturnType; + readonly off: ReturnType; + readonly ipc: { readonly off: ReturnType }; + }; + fromId.mockImplementation((id) => { + if (id === 42) return firstWebContents; + if (id === 43) return replacementWebContents; + return null; + }); + const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; }); - expect(artifact.path).toMatch( - /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, - ); - // Chromium reports UnknownVizError while a hidden guest warms its - // first compositor frame, so transient failures are retried. - capturePage.mockClear(); - capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); - capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); - const retriedFiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* TestClock.adjust(1_000); - const retriedExit = yield* Fiber.join(retriedFiber); - expect(Exit.isSuccess(retriedExit)).toBe(true); - expect(capturePage).toHaveBeenCalledTimes(3); - - capturePage.mockClear(); - const captureCause = new Error("capture failed"); - capturePage.mockRejectedValue(captureCause); - const failingFiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( - Effect.forkChild({ startImmediately: true }), + yield* manager.createTab("tab_close_register_race"); + yield* manager.registerWebview("tab_close_register_race", 42); + yield* manager.openPictureInPicture("tab_close_register_race"); + + const closeCleanupPaused = yield* Deferred.make(); + const continueCloseCleanup = yield* Deferred.make(); + yield* manager.subscribeStateChanges((_tabId, state) => + !state.pictureInPicture && state.webContentsId === 42 + ? Deferred.succeed(closeCleanupPaused, undefined).pipe( + Effect.andThen(Deferred.await(continueCloseCleanup)), + ) + : Effect.void, ); - yield* TestClock.adjust(1_000); - const exit = yield* Fiber.join(failingFiber); - expect(Exit.isFailure(exit)).toBe(true); - expect(capturePage).toHaveBeenCalledTimes(3); - if (Exit.isSuccess(exit)) return; - const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); - expect(error).toMatchObject({ - _tag: "PreviewOperationError", - operation: "captureScreenshot.capturePage", - tabId: "tab_1", - webContentsId: 42, - cause: captureCause, - }); + + const closeFiber = yield* manager + .closeTab("tab_close_register_race") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(closeCleanupPaused); + const recreateFiber = yield* manager + .createTab("tab_close_register_race") + .pipe(Effect.forkChild({ startImmediately: true })); + const registrationFiber = yield* manager + .registerWebview("tab_close_register_race", 43) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(replacementListenerSpies.on).not.toHaveBeenCalled(); + yield* manager.closeTab("tab_close_register_race"); + const recordingExit = yield* Effect.exit(manager.startRecording("tab_close_register_race")); + yield* Deferred.succeed(continueCloseCleanup, undefined); + yield* Fiber.join(closeFiber); + const recreated = yield* Fiber.join(recreateFiber); + const registrationExit = yield* Fiber.await(registrationFiber); + + for (const exit of [registrationExit, recordingExit] as ReadonlyArray< + Exit.Exit + >) { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) continue; + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewTabNotFoundError", + tabId: "tab_close_register_race", + }); + } + expect(replacementListenerSpies.on).not.toHaveBeenCalled(); + expect(replacementListenerSpies.off).not.toHaveBeenCalled(); + expect(replacementListenerSpies.ipc.off).not.toHaveBeenCalled(); + expect(capturePage).toHaveBeenCalledOnce(); + expect(recreated.webContentsId).toBeNull(); }), ), ); @@ -2716,75 +1483,73 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("rolls back window throttling when a recorded guest cannot be unthrottled", () => + effectIt.effect("keeps frame capture closed when a replacement is destroyed during cleanup", () => withManager((manager) => Effect.gen(function* () { - const setWindowBackgroundThrottling = vi.fn(); + const windowEvents = new Map void>(); + const host = makeTestHostWebContents(); const capturePage = vi.fn(async () => ({ toJPEG: () => Buffer.from("recording-frame"), getSize: () => ({ width: 1280, height: 720 }), })); - const wc = makeTestPreviewWebContents(capturePage); - fromId.mockReturnValue(wc); - - yield* manager.createTab("tab_guest_throttling_failure"); - yield* manager.registerWebview("tab_guest_throttling_failure", 42); + const guest = makeTestPreviewWebContents(capturePage, 42, host); + fromId.mockReturnValue(guest); + yield* manager.createTab("tab_shutdown"); + yield* manager.registerWebview("tab_shutdown", 42); yield* manager.setMainWindow({ isDestroyed: () => false, - once: vi.fn(), - webContents: { setBackgroundThrottling: setWindowBackgroundThrottling }, + once: (event: string, listener: () => void) => windowEvents.set(event, listener), + webContents: { setBackgroundThrottling: vi.fn() }, } as never); - - wc.setBackgroundThrottling.mockImplementationOnce(() => { - throw new Error("guest throttling update failed"); + yield* manager.startRecording("tab_shutdown"); + const pip = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pip.pictureInPictureWindow; }); - const failedStart = yield* Effect.exit( - manager.startRecording("tab_guest_throttling_failure"), + yield* manager.openPictureInPicture("tab_shutdown"); + const capturesBeforeClose = capturePage.mock.calls.length; + const cleanupStarted = yield* Deferred.make(); + const releaseCleanup = yield* Deferred.make(); + yield* manager.subscribeStateChanges((_tabId, state) => + state.pictureInPicture + ? Effect.void + : Deferred.succeed(cleanupStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCleanup)), + ), ); - expect(Exit.isFailure(failedStart)).toBe(true); - expect(setWindowBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); - - yield* manager.startRecording("tab_guest_throttling_failure"); - yield* manager.stopRecording("tab_guest_throttling_failure"); - expect(setWindowBackgroundThrottling.mock.calls).toEqual([ - [false], - [true], - [false], - [true], - ]); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [false], [true]]); - }), - ), - ); - - effectIt.effect("does not publish a replacement window when capture reconciliation fails", () => - withManager((manager) => - Effect.gen(function* () { - const setBackgroundThrottling = vi.fn(() => { - throw new Error("replacement throttling update failed"); - }); - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - yield* manager.createTab("tab_capture_replacement_failure"); - yield* manager.registerWebview("tab_capture_replacement_failure", 42); - yield* manager.startRecording("tab_capture_replacement_failure"); - - const failedReplacement = yield* Effect.exit( - manager.setMainWindow({ - isDestroyed: () => false, + windowEvents.get("closed")!(); + yield* Deferred.await(cleanupStarted); + let destroyed = false; + const replacement = yield* manager + .setMainWindow({ + isDestroyed: () => destroyed, once: vi.fn(), - webContents: { setBackgroundThrottling }, - } as never), - ); - expect(Exit.isFailure(failedReplacement)).toBe(true); + webContents: { setBackgroundThrottling: vi.fn() }, + } as never) + .pipe(Effect.forkChild({ startImmediately: true })); + try { + expect(replacement.pollUnsafe()).toBeUndefined(); + destroyed = true; + } finally { + yield* Deferred.succeed(releaseCleanup, undefined); + } + yield* Fiber.join(replacement); - yield* manager.stopRecording("tab_capture_replacement_failure"); - expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); + const restart = yield* Effect.exit(manager.startRecording("tab_shutdown")); + expect(Exit.isFailure(restart)).toBe(true); + if (Exit.isFailure(restart)) { + expect(Option.getOrThrow(Cause.findErrorOption(restart.cause))).toMatchObject({ + _tag: "PreviewMainWindowClosedError", + tabId: "tab_shutdown", + }); + } + expect(capturePage).toHaveBeenCalledTimes(capturesBeforeClose); + expect(pip.pictureInPictureWindow.isDestroyed()).toBe(true); + expect(guest.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + const grants: Array<{ video?: unknown }> = []; + host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => grants.push(value)); + expect(grants).toEqual([{}]); }), ), ); @@ -2970,36 +1735,6 @@ describe("PreviewManager", () => { }).pipe(Effect.provide(managerLayer()), Effect.scoped), ); - effectIt.effect("stops capture retries when the tab swaps during the retry delay", () => - withManager((manager) => - Effect.gen(function* () { - const capturePage = vi.fn(async () => ({ - toPNG: () => Buffer.from("png"), - toJPEG: () => Buffer.from("jpeg"), - getSize: () => ({ width: 100, height: 80 }), - })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42)); - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); - - capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); - const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* TestClock.adjust(60); - expect(capturePage).toHaveBeenCalledTimes(1); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43)); - yield* manager.registerWebview("tab_1", 43); - yield* TestClock.adjust(1_000); - const exit = yield* Fiber.join(fiber); - - expect(Exit.isFailure(exit)).toBe(true); - expect(capturePage).toHaveBeenCalledTimes(1); - expect(writeFile).not.toHaveBeenCalled(); - }), - ), - ); - effectIt.effect("discards a screenshot that resolves after its guest is replaced", () => withManager((manager) => Effect.gen(function* () { @@ -3128,95 +1863,6 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("requests display media with a fresh renderer gesture", () => - withManager((manager) => - Effect.gen(function* () { - const { host, takeGrant } = yield* setupRecordingRaceTabs(manager); - - yield* manager.startRecording("tab_race_a"); - - expect(host.executeJavaScript).toHaveBeenCalledWith( - expect.stringContaining(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER), - true, - ); - expect(host.executeJavaScript).toHaveBeenCalledWith( - expect.stringContaining("tab_race_a"), - true, - ); - takeGrant(); - yield* manager.stopRecording("tab_race_a"); - }), - ), - ); - - effectIt.live("settles both starts when two tabs race for the capture stream", () => - withManager((manager) => - Effect.gen(function* () { - const { host, grants, takeGrant } = yield* setupRecordingRaceTabs(manager); - - const exits = yield* Effect.all( - [ - Effect.exit(manager.startRecording("tab_race_a")), - Effect.exit(manager.startRecording("tab_race_b")), - ], - { concurrency: 2 }, - ); - - const [exitA, exitB] = exits; - expect(exits.filter(Exit.isSuccess)).toHaveLength(1); - const loserExit = Exit.isSuccess(exitA) ? exitB : exitA; - if (Exit.isSuccess(loserExit)) return; - expect(Option.getOrThrow(Cause.findErrorOption(loserExit.cause))).toMatchObject({ - _tag: "PreviewRecordingArmConflictError", - }); - - takeGrant(); - expect(grants).toEqual([{ video: { routingId: Exit.isSuccess(exitA) ? 41 : 42 } }]); - expect(host.session.setDisplayMediaRequestHandler).toHaveBeenCalledOnce(); - }), - ), - ); - - effectIt.effect("releases an armed slot that the renderer never redeemed", () => - withManager((manager) => - Effect.gen(function* () { - const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); - - yield* manager.startRecording("tab_race_a"); - const blocked = yield* Effect.exit(manager.startRecording("tab_race_b")); - if (Exit.isSuccess(blocked)) throw new Error("expected the second tab to be refused"); - expect(Option.getOrThrow(Cause.findErrorOption(blocked.cause))).toMatchObject({ - _tag: "PreviewRecordingArmConflictError", - tabId: "tab_race_b", - armedTabId: "tab_race_a", - }); - - yield* TestClock.adjust(10_000); - yield* manager.startRecording("tab_race_b"); - takeGrant(); - expect(grants).toEqual([{ video: { routingId: 42 } }]); - - yield* manager.stopRecording("tab_race_a"); - yield* manager.stopRecording("tab_race_b"); - }), - ), - ); - - effectIt.effect("denies a display-media request that arrives after the arm went stale", () => - withManager((manager) => - Effect.gen(function* () { - const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); - - yield* manager.startRecording("tab_race_a"); - yield* TestClock.adjust(10_000); - takeGrant(); - expect(grants).toEqual([{}]); - - yield* manager.stopRecording("tab_race_a"); - }), - ), - ); - effectIt.effect("only lets the host frame that armed a recording claim its stream", () => withManager((manager) => Effect.gen(function* () { @@ -3241,96 +1887,9 @@ describe("PreviewManager", () => { destroy(41); yield* manager.startRecording("tab_race_b"); takeGrant(); - expect(grants).toEqual([{ video: { routingId: 42 } }]); - - yield* manager.stopRecording("tab_race_b"); - }), - ), - ); - - effectIt.effect( - "restores the native cursor when recording startup fails, then allows a retry", - () => - withManager((manager) => - Effect.gen(function* () { - const host = makeTestHostWebContents(); - host.executeJavaScript.mockResolvedValueOnce(false); - let cursorActive = false; - const cursorAtCapture: boolean[] = []; - const contents = Object.assign( - makeTestPreviewWebContents( - async () => { - cursorAtCapture.push(cursorActive); - return { - toJPEG: () => Buffer.from("frame"), - getSize: () => ({ width: 800, height: 600 }), - }; - }, - 42, - host, - ), - { - send: (channel: string, active: unknown) => { - if (channel === "preview:recording-cursor") cursorActive = active === true; - }, - }, - ); - fromId.mockReturnValue(contents as never); - yield* manager.createTab("tab_cursor"); - yield* manager.registerWebview("tab_cursor", 42); - const failed = yield* Effect.exit(manager.startRecording("tab_cursor")); - expect(Exit.isFailure(failed)).toBe(true); - expect(cursorAtCapture).toEqual([true]); - expect(cursorActive).toBe(false); - - yield* manager.startRecording("tab_cursor"); - expect(cursorAtCapture).toEqual([true, true]); - expect(cursorActive).toBe(true); - yield* manager.stopRecording("tab_cursor"); - expect(cursorActive).toBe(false); - }), - ), - ); - - effectIt.effect("restores the recording cursor after navigation only while recording", () => - withManager((manager) => - Effect.gen(function* () { - const listeners = new Map void>(); - let cursorActive = false; - let cursorUpdated: (() => void) | undefined; - let inputOptions: unknown; - const options = { showKeyPresses: true, showMousePresses: false }; - const contents = Object.assign( - makeTestPreviewWebContents(async () => ({ - toJPEG: () => Buffer.from("frame"), - getSize: () => ({ width: 800, height: 600 }), - })), - { - on: (event: string, listener: () => void) => listeners.set(event, listener), - send: (channel: string, active: unknown, recordingOptions: unknown) => { - if (channel !== "preview:recording-cursor") return; - cursorActive = active === true; - inputOptions = recordingOptions; - cursorUpdated?.(); - }, - }, - ); - fromId.mockReturnValue(contents as never); - yield* manager.createTab("tab_cursor_reload"); - yield* manager.registerWebview("tab_cursor_reload", 42); - yield* manager.startRecording("tab_cursor_reload", options); - for (const recording of [true, false]) { - if (!recording) yield* manager.stopRecording("tab_cursor_reload"); - cursorActive = false; - const restored = new Promise((resolve) => { - cursorUpdated = resolve; - }); - listeners.get("dom-ready")?.(); - yield* Effect.promise(() => restored); - cursorUpdated = undefined; - expect(cursorActive).toBe(recording); - expect(inputOptions).toEqual(recording ? options : undefined); - } + expect(grants).toEqual([{ video: { routingId: 42 } }]); + + yield* manager.stopRecording("tab_race_b"); }), ), ); @@ -3406,85 +1965,6 @@ describe("PreviewManager", () => { ), ); - effectIt.effect( - "releases queued STOP after stalled recording warmups and ignores late completion", - () => - withManager((manager) => - Effect.gen(function* () { - const warmupStarted = Promise.withResolvers(); - const firstWarmup = Promise.withResolvers(); - const lateWarmup = Promise.withResolvers(); - const capturePage = vi - .fn<() => Promise>() - .mockImplementationOnce(() => { - warmupStarted.resolve(); - return firstWarmup.promise; - }) - .mockImplementation(() => lateWarmup.promise); - const host = makeTestHostWebContents(); - const webContents = makeTestPreviewWebContents(capturePage, 42, host); - const windowThrottling = vi.fn(); - fromId.mockReturnValue(webContents); - yield* manager.createTab("tab_recording_stalled_warmup"); - yield* manager.registerWebview("tab_recording_stalled_warmup", 42); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: { setBackgroundThrottling: windowThrottling }, - } as never); - - const image = { - toJPEG: () => Buffer.from("late-recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - }; - try { - const start = yield* manager - .startRecording("tab_recording_stalled_warmup") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => warmupStarted.promise); - let stopped = false; - const stop = yield* manager.stopRecording("tab_recording_stalled_warmup").pipe( - Effect.andThen( - Effect.sync(() => { - stopped = true; - }), - ), - Effect.forkChild({ startImmediately: true }), - ); - expect(webContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); - expect(windowThrottling.mock.calls).toEqual([[false]]); - yield* TestClock.adjust(1_000); - expect(stopped).toBe(false); - expect(host.executeJavaScript).not.toHaveBeenCalled(); - yield* TestClock.adjust(1_000); - expect(stopped).toBe(true); - yield* Fiber.join(start); - yield* Fiber.join(stop); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(host.executeJavaScript).toHaveBeenCalledOnce(); - expect(webContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - expect(windowThrottling.mock.calls).toEqual([[false], [true]]); - expect(webviewSend).toHaveBeenLastCalledWith("preview:recording-cursor", false); - - lateWarmup.resolve(image); - yield* TestClock.adjust(0); - const streams: Array<{ video?: unknown }> = []; - host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => streams.push(value)); - expect(streams).toEqual([{}]); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(host.executeJavaScript).toHaveBeenCalledOnce(); - expect(webContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - expect(windowThrottling.mock.calls).toEqual([[false], [true]]); - expect(webviewSend).toHaveBeenLastCalledWith("preview:recording-cursor", false); - } finally { - // Release stalled native calls after a failing assertion against unchanged base. - firstWarmup.resolve(image); - lateWarmup.resolve(image); - } - }), - ), - ); - effectIt.effect("continues native recording when the source warmup fails", () => withManager((manager) => Effect.gen(function* () { @@ -3536,428 +2016,41 @@ describe("PreviewManager", () => { }), ) .mockResolvedValue(capturedImage); - const initialWebContents = makeTestPreviewWebContents(capturePage, 42); - const replacementOn = vi.fn(); - const replacementWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 43), { - on: replacementOn, - }); - fromId.mockImplementation((id) => { - if (id === 42) return initialWebContents; - if (id === 43) return replacementWebContents; - return null; - }); - - yield* manager.createTab("tab_recording_replacement_race"); - yield* manager.registerWebview("tab_recording_replacement_race", 42); - const start = yield* manager - .startRecording("tab_recording_replacement_race") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => warmupStarted); - const replacement = yield* manager - .registerWebview("tab_recording_replacement_race", 43) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.yieldNow; - expect(replacementOn).not.toHaveBeenCalled(); - - finishWarmup(capturedImage); - yield* Fiber.join(start); - yield* Fiber.join(replacement); - expect(replacementOn).toHaveBeenCalled(); - expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); - expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); - yield* manager.stopRecording("tab_recording_replacement_race"); - expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([ - [false], - [true], - ]); - }), - ), - ); - - effectIt.effect("shares background frame capture between recording and picture-in-picture", () => - withManager((manager) => - Effect.gen(function* () { - const setBackgroundThrottling = vi.fn(); - const mainWindowWebContents = Object.assign(makeTestHostWebContents(), { - setBackgroundThrottling, - }); - const jpeg = Buffer.from("shared-preview-frame"); - const capturePage = vi.fn(async () => ({ - toJPEG: () => jpeg, - toBitmap: () => jpeg, - getSize: () => ({ width: 1280, height: 720 }), - })); - fromId.mockReturnValue({ - id: 42, - mainFrame: { routingId: 42 }, - hostWebContents: mainWindowWebContents, - executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - setBackgroundThrottling: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - capturePage, - } as never); - - const pictureInPictureListeners = new Map void>(); - const pictureInPictureSend = vi.fn(); - const pictureInPictureWindow = { - isDestroyed: vi.fn(() => false), - once: vi.fn((event: string, listener: () => void) => { - pictureInPictureListeners.set(event, listener); - }), - setAlwaysOnTop: vi.fn(), - setVisibleOnAllWorkspaces: vi.fn(), - setAspectRatio: vi.fn(), - getContentSize: vi.fn(() => [480, 320] as [number, number]), - setContentSize: vi.fn(), - loadURL: vi.fn(async () => undefined), - showInactive: vi.fn(), - close: vi.fn(() => { - pictureInPictureListeners.get("closed")?.(); - }), - webContents: { - on: vi.fn(), - off: vi.fn(), - send: pictureInPictureSend, - }, - }; - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; - }); - const states: PreviewManager.PreviewTabState[] = []; - const recordingFrames: DesktopPreviewRecordingFrame[] = []; - - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: mainWindowWebContents, - } as never); - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.subscribeRecordingFrames((frame) => - Effect.sync(() => { - recordingFrames.push(frame); - }), - ); - yield* manager.createTab("tab_pip"); - yield* manager.registerWebview("tab_pip", 42); - yield* manager.openPictureInPicture("tab_pip"); - - expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); - expect(browserWindowConstructor).toHaveBeenCalledWith( - expect.objectContaining({ - alwaysOnTop: true, - show: false, - skipTaskbar: true, - webPreferences: expect.objectContaining({ - preload: "/tmp/t3/desktop/preview-pip-preload.cjs", - backgroundThrottling: false, - }), - }), - ); - expect(pictureInPictureWindow.showInactive).toHaveBeenCalledOnce(); - expect(pictureInPictureWindow.setVisibleOnAllWorkspaces).toHaveBeenCalledWith(true, { - visibleOnFullScreen: true, - skipTransformProcessType: true, - }); - expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]); - expect(pictureInPictureWindow.setContentSize).toHaveBeenCalledWith(523, 294, false); - expect(pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[0]).toBeLessThan( - pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0] ?? 0, - ); - expect(pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0]).toBeLessThan( - pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[1] ?? 0, - ); - expect(pictureInPictureSend).toHaveBeenCalledWith( - "desktop:preview-pip-frame", - expect.objectContaining({ - tabId: "tab_pip", - data: jpeg.toString("base64"), - width: 1280, - height: 720, - }), - ); - expect(states.at(-1)?.pictureInPicture).toBe(true); - expect(capturePage).toHaveBeenCalledOnce(); - const pictureInPictureFramesBeforeRecording = pictureInPictureSend.mock.calls.length; - - yield* manager.startRecording("tab_pip"); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(recordingFrames).toHaveLength(0); - - yield* TestClock.adjust(100); - expect(capturePage).toHaveBeenCalledTimes(3); - expect(pictureInPictureSend).toHaveBeenCalledTimes(pictureInPictureFramesBeforeRecording); - expect(recordingFrames).toHaveLength(0); - - yield* manager.stopRecording("tab_pip"); - expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); - const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length; - yield* TestClock.adjust(100); - expect(capturePage).toHaveBeenCalledTimes(4); - expect(pictureInPictureSend.mock.calls.length).toBe(framesBeforePictureInPictureOnlyTick); - expect(recordingFrames).toHaveLength(0); - - setBackgroundThrottling.mockImplementationOnce(() => { - throw new Error("picture-in-picture throttling restore failed"); - }); - yield* manager.closePictureInPicture("tab_pip"); - expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true], [true]]); - expect(pictureInPictureWindow.close).toHaveBeenCalledOnce(); - expect(states.at(-1)?.pictureInPicture).toBe(false); - const capturesAfterClose = capturePage.mock.calls.length; - yield* TestClock.adjust(200); - expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose); - }), - ), - ); - - effectIt.effect("keeps picture-in-picture capture separate from recording warmup", () => - withManager((manager) => - Effect.gen(function* () { - const jpeg = Buffer.from("shared-preview-frame"); - const capturePage = vi.fn(async () => ({ - toJPEG: () => jpeg, - toBitmap: () => jpeg, - getSize: () => ({ width: 1280, height: 720 }), - })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; - }); - const recordingFrames: DesktopPreviewRecordingFrame[] = []; - - yield* manager.subscribeRecordingFrames((frame) => - Effect.sync(() => { - recordingFrames.push(frame); - }), - ); - yield* manager.createTab("tab_recording_then_pip"); - yield* manager.registerWebview("tab_recording_then_pip", 42); - yield* manager.startRecording("tab_recording_then_pip"); - - expect(recordingFrames).toHaveLength(0); - expect(capturePage).toHaveBeenCalledOnce(); - yield* manager.openPictureInPicture("tab_recording_then_pip"); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(send).toHaveBeenCalledOnce(); - - yield* TestClock.adjust(100); - - expect(capturePage).toHaveBeenCalledTimes(3); - expect(recordingFrames).toHaveLength(0); - expect(send).toHaveBeenCalledOnce(); - yield* manager.closePictureInPicture("tab_recording_then_pip"); - yield* manager.stopRecording("tab_recording_then_pip"); - }), - ), - ); - - effectIt.effect("stops frame capture when the native picture-in-picture window closes", () => - withManager((manager) => - Effect.gen(function* () { - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("native-close-preview-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; - }); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - - yield* manager.createTab("tab_native_pip_close"); - yield* manager.registerWebview("tab_native_pip_close", 42); - yield* manager.openPictureInPicture("tab_native_pip_close"); - - pictureInPictureWindow.close(); - yield* settle(() => states.at(-1)?.pictureInPicture === false); - - expect(states.at(-1)?.pictureInPicture).toBe(false); - const capturesAfterClose = capturePage.mock.calls.length; - yield* TestClock.adjust(200); - expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose); - }), - ), - ); - - effectIt.effect("retries an unchanged picture-in-picture frame after delivery fails", () => - withManager((manager) => - Effect.gen(function* () { - const jpeg = Buffer.from("retry-preview-frame"); - const capturePage = vi.fn(async () => ({ - toJPEG: () => jpeg, - toBitmap: () => jpeg, - getSize: () => ({ width: 1280, height: 720 }), - })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); - send.mockImplementationOnce(() => { - throw new Error("picture-in-picture delivery failed"); - }); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; - }); - - yield* manager.createTab("tab_pip_delivery_retry"); - yield* manager.registerWebview("tab_pip_delivery_retry", 42); - yield* manager.openPictureInPicture("tab_pip_delivery_retry"); - expect(send).toHaveBeenCalledOnce(); - - yield* TestClock.adjust(100); - - expect(capturePage).toHaveBeenCalledTimes(2); - expect(send).toHaveBeenCalledTimes(2); - yield* manager.closePictureInPicture("tab_pip_delivery_retry"); - }), - ), - ); - - effectIt.effect("replays an unchanged picture-in-picture frame after its renderer reloads", () => - withManager((manager) => - Effect.gen(function* () { - const jpeg = Buffer.from("reloaded-preview-frame"); - const capturePage = vi.fn(async () => ({ - toJPEG: () => jpeg, - toBitmap: () => jpeg, - getSize: () => ({ width: 1280, height: 720 }), - })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send, webContentsListeners } = - makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; - }); - - yield* manager.createTab("tab_pip_reload"); - yield* manager.registerWebview("tab_pip_reload", 42); - yield* manager.openPictureInPicture("tab_pip_reload"); - expect(send).toHaveBeenCalledOnce(); - - webContentsListeners.get("did-finish-load")?.(); - yield* TestClock.adjust(100); - - expect(send).toHaveBeenCalledTimes(2); - yield* manager.closePictureInPicture("tab_pip_reload"); - }), - ), - ); - - effectIt.effect("encodes a picture-in-picture frame only when the page changes", () => - withManager((manager) => - Effect.gen(function* () { - let page = "static-page"; - let pixelsReadable = true; - const toJPEG = vi.fn((pixels: string) => Buffer.from(`jpeg:${pixels}`)); - const capturePage = vi.fn(async () => { - const pixels = page; - return { - toJPEG: () => toJPEG(pixels), - toBitmap: () => (pixelsReadable ? Buffer.from(pixels) : Buffer.alloc(0)), - getSize: () => ({ width: 1280, height: 720 }), - }; - }); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; - }); - - yield* manager.createTab("tab_pip_unchanged"); - yield* manager.registerWebview("tab_pip_unchanged", 42); - yield* manager.openPictureInPicture("tab_pip_unchanged"); - yield* TestClock.adjust(100); - - expect(capturePage).toHaveBeenCalledTimes(2); - expect(toJPEG).toHaveBeenCalledOnce(); - expect(send).toHaveBeenCalledOnce(); - - page = "changed-page"; - yield* TestClock.adjust(100); - - expect(toJPEG).toHaveBeenCalledTimes(2); - expect(send).toHaveBeenCalledTimes(2); - expect(send).toHaveBeenLastCalledWith( - "desktop:preview-pip-frame", - expect.objectContaining({ data: Buffer.from("jpeg:changed-page").toString("base64") }), - ); - - // A frame whose pixels cannot be read has no fingerprint and is still delivered. - pixelsReadable = false; - yield* TestClock.adjust(100); - - expect(toJPEG).toHaveBeenCalledTimes(3); - expect(send).toHaveBeenCalledTimes(3); - yield* manager.closePictureInPicture("tab_pip_unchanged"); - }), - ), - ); - - effectIt.effect("drops empty frames before picture-in-picture delivery", () => - withManager((manager) => - Effect.gen(function* () { - const validImage: TestCapturedPreviewImage = { - toJPEG: () => Buffer.from("valid-preview-frame"), - toBitmap: () => Buffer.from("valid-preview-frame"), - getSize: () => ({ width: 1280, height: 720 }), - }; - const capturePage = vi.fn(async () => validImage); - capturePage.mockResolvedValueOnce({ - toJPEG: () => Buffer.from("empty-preview-frame"), - getSize: () => ({ width: 0, height: 0 }), + const initialWebContents = makeTestPreviewWebContents(capturePage, 42); + const replacementOn = vi.fn(); + const replacementWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 43), { + on: replacementOn, }); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; + fromId.mockImplementation((id) => { + if (id === 42) return initialWebContents; + if (id === 43) return replacementWebContents; + return null; }); - yield* manager.createTab("tab_empty_frame"); - yield* manager.registerWebview("tab_empty_frame", 42); - yield* manager.openPictureInPicture("tab_empty_frame"); - - expect(capturePage).toHaveBeenCalledOnce(); - expect(pictureInPictureWindow.setAspectRatio).not.toHaveBeenCalled(); - expect(send).not.toHaveBeenCalled(); - - yield* TestClock.adjust(100); + yield* manager.createTab("tab_recording_replacement_race"); + yield* manager.registerWebview("tab_recording_replacement_race", 42); + const start = yield* manager + .startRecording("tab_recording_replacement_race") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => warmupStarted); + const replacement = yield* manager + .registerWebview("tab_recording_replacement_race", 43) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(replacementOn).not.toHaveBeenCalled(); - expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]); - expect(send).toHaveBeenCalledOnce(); - yield* manager.closePictureInPicture("tab_empty_frame"); + finishWarmup(capturedImage); + yield* Fiber.join(start); + yield* Fiber.join(replacement); + expect(replacementOn).toHaveBeenCalled(); + expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + yield* manager.stopRecording("tab_recording_replacement_race"); + expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([ + [false], + [true], + ]); }), ), ); @@ -4283,287 +2376,172 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("settles the pick when the annotation screenshot never arrives", () => - withManager((manager) => - Effect.gen(function* () { - let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - isFocused: () => true, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - once: vi.fn(), - off: vi.fn(), - capturePage: vi.fn(() => new Promise(() => {})), - ipc: { - on: vi.fn((channel: string, listener: typeof onPicked) => { - if (channel === "preview:element-picked") onPicked = listener; + describe("annotation screenshots", () => { + const captureAnnotation = ( + cropRect: PreviewAnnotationRect | null, + targets: Partial> = {}, + emptyImage?: "viewport" | "crop", + ) => + withManager((manager) => + Effect.gen(function* () { + let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; + const crop = vi.fn((rect: Electron.Rectangle) => ({ + isEmpty: () => emptyImage === "crop", + getSize: () => + emptyImage === "crop" + ? { width: 0, height: 0 } + : { width: rect.width, height: rect.height }, + toDataURL: () => (emptyImage === "crop" ? "data:," : "data:image/png;base64,cropped"), + })); + const capturePage = vi.fn(async () => ({ + isEmpty: () => emptyImage === "viewport", + getSize: () => + emptyImage === "viewport" ? { width: 0, height: 0 } : { width: 2000, height: 1200 }, + toJPEG: () => Buffer.from("unused"), + toDataURL: () => (emptyImage === "viewport" ? "data:," : "data:image/png;base64,full"), + crop, + })); + fromId.mockReturnValue( + Object.assign(makeTestPreviewWebContents(capturePage), { + isFocused: () => true, + once: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof onPicked) => { + if (channel === "preview:element-picked") onPicked = listener; + }), + off: vi.fn(), + removeListener: vi.fn(), + }, }), - off: vi.fn(), - removeListener: vi.fn(), - }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never); - - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); - const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); - yield* Effect.yieldNow; + ); - yield* manager.setAnnotationSendEnabled("tab_1", true); - onPicked?.( - {}, - { - id: "annotation_1", - pageUrl: "https://example.com", - pageTitle: "Example", - comment: "Tighten this spacing", - elements: [], - regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], - strokes: [], - styleChanges: [], - screenshot: null, - createdAt: "2026-06-11T00:00:00.000Z", - }, - null, - "send", - ); - yield* Effect.yieldNow; - expect(pick.pollUnsafe()).toBeUndefined(); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + onPicked?.( + {}, + { + id: "annotation_1", + pageUrl: "https://example.com", + pageTitle: "Example", + comment: "Tighten this spacing", + elements: [], + regions: [], + strokes: [], + ...targets, + styleChanges: [], + screenshot: null, + createdAt: "2026-06-11T00:00:00.000Z", + }, + cropRect, + "attach", + 2, + ); - yield* TestClock.adjust("6 seconds"); - const result = yield* Fiber.join(pick); - expect(result?.annotation.screenshot).toBeNull(); - expect(result?.screenshotFailed).toBe(true); - expect(result?.submission).toBe("send"); - expect(webviewSend).toHaveBeenCalledWith("preview:annotation-captured"); - }), - ), - ); + const result = yield* Fiber.join(pick); + expect(capturePage).toHaveBeenCalledExactlyOnceWith(); + expect(result?.annotation.comment).toBe("Tighten this spacing"); + expect(result?.submission).toBe("attach"); + return { result, crop }; + }), + ); - effectIt.effect("crops the picked area out of a full-page capture at device scale", () => - withManager((manager) => + effectIt.effect("clips a partially visible selection to the captured viewport", () => Effect.gen(function* () { - let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; - const crop = vi.fn((rect: Electron.Rectangle) => ({ - getSize: () => ({ width: rect.width, height: rect.height }), - toDataURL: () => "data:image/png;base64,cropped", - })); - const capturePage = vi.fn(async () => ({ - getSize: () => ({ width: 2000, height: 1200 }), - crop, - })); - fromId.mockReturnValue( - Object.assign(makeTestPreviewWebContents(capturePage as never), { - isFocused: () => true, - once: vi.fn(), - ipc: { - on: vi.fn((channel: string, listener: typeof onPicked) => { - if (channel === "preview:element-picked") onPicked = listener; - }), - off: vi.fn(), - removeListener: vi.fn(), - }, - }), - ); - - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); - const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); - yield* Effect.yieldNow; - onPicked?.( - {}, - { - id: "annotation_1", - pageUrl: "https://example.com", - pageTitle: "Example", - comment: "Tighten this spacing", - elements: [], - regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], - strokes: [], - styleChanges: [], - screenshot: null, - createdAt: "2026-06-11T00:00:00.000Z", - }, - { x: 5, y: 6, width: 20, height: 30 }, - "attach", - 2, + const { result, crop } = yield* captureAnnotation( + { x: -40, y: 570, width: 240, height: 110 }, + { regions: [{ id: "region_1", rect: { x: -20, y: 590, width: 200, height: 70 } }] }, ); - - const result = yield* Fiber.join(pick); - expect(capturePage).toHaveBeenCalledWith(); - expect(crop).toHaveBeenCalledWith({ x: 10, y: 12, width: 40, height: 60 }); + expect(crop).toHaveBeenCalledExactlyOnceWith({ x: 0, y: 1140, width: 400, height: 60 }); expect(result?.screenshotFailed).toBeUndefined(); - expect(result?.annotation.screenshot).toEqual({ + expect(result?.annotation.screenshot).toMatchObject({ dataUrl: "data:image/png;base64,cropped", - width: 40, + width: 400, height: 60, - cropRect: { x: 5, y: 6, width: 20, height: 30 }, }); }), - ), - ); + ); - effectIt.effect("a stale capture from a replaced pick never touches the next pick", () => - withManager((manager) => + effectIt.effect.each([ + { + name: "an offscreen crop without individual target bounds", + cropRect: { x: 1010, y: 40, width: 100, height: 70 }, + regions: [], + }, + { + name: "padding overlapping the viewport while its target is offscreen", + cropRect: { x: 40, y: -100, width: 240, height: 110 }, + regions: [{ id: "region_1", rect: { x: 60, y: -80, width: 200, height: 70 } }], + }, + { + name: "a union spanning the viewport with both targets offscreen", + cropRect: { x: -230, y: 40, width: 1460, height: 110 }, + regions: [ + { id: "region_1", rect: { x: -210, y: 60, width: 200, height: 70 } }, + { id: "region_2", rect: { x: 1010, y: 60, width: 200, height: 70 } }, + ], + }, + { + name: "an offscreen target when crop bounds are missing", + cropRect: null, + regions: [{ id: "region_1", rect: { x: 60, y: -80, width: 200, height: 70 } }], + }, + ])("does not attach $name", ({ cropRect, regions }) => Effect.gen(function* () { - let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - isFocused: () => true, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - once: vi.fn(), - off: vi.fn(), - capturePage: vi.fn(() => new Promise(() => {})), - ipc: { - on: vi.fn((channel: string, listener: typeof onPicked) => { - if (channel === "preview:element-picked") onPicked = listener; - }), - off: vi.fn(), - removeListener: vi.fn(), - }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never); - const annotation = { - id: "annotation_1", - pageUrl: "https://example.com", - pageTitle: "Example", - comment: "Tighten this spacing", - elements: [], - regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], - strokes: [], - styleChanges: [], - screenshot: null, - createdAt: "2026-06-11T00:00:00.000Z", - }; - - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); - const firstPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); - yield* Effect.yieldNow; - onPicked?.({}, annotation, null, "send"); - yield* Effect.yieldNow; - - const secondPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); - yield* Effect.yieldNow; - expect(yield* Fiber.join(firstPick)).toBeNull(); - webviewSend.mockClear(); - - yield* TestClock.adjust("6 seconds"); - yield* Effect.yieldNow; - expect(webviewSend).not.toHaveBeenCalledWith("preview:annotation-captured"); - expect(secondPick.pollUnsafe()).toBeUndefined(); - - onPicked?.({}, { ...annotation, id: "annotation_2" }, null, "attach"); - yield* TestClock.adjust("6 seconds"); - const result = yield* Fiber.join(secondPick); - expect(result?.annotation.id).toBe("annotation_2"); - expect(result?.submission).toBe("attach"); + const { result, crop } = yield* captureAnnotation(cropRect, { regions }); + expect(crop).not.toHaveBeenCalled(); + expect(result?.annotation.screenshot).toBeNull(); + expect(result?.screenshotFailed).toBe(true); }), - ), - ); + ); - effectIt.effect("navigates the guest history when the thumb-button ipc fires", () => - withManager((manager) => + // regression: #18441 zero-area strokes on the viewport edge must still get a screenshot crop + effectIt.effect.each([ + { + name: "zero-width SVG line on the left edge", + rect: { x: 0, y: 60, width: 0, height: 70 }, + cropRect: { x: -20, y: 40, width: 40, height: 110 }, + expectedCrop: { x: 0, y: 80, width: 40, height: 220 }, + }, + { + name: "zero-height SVG line on the top edge", + rect: { x: 60, y: 0, width: 70, height: 0 }, + cropRect: { x: 40, y: -20, width: 110, height: 40 }, + expectedCrop: { x: 80, y: 0, width: 220, height: 40 }, + }, + ])("captures a stroked $name", ({ rect, cropRect, expectedCrop }) => Effect.gen(function* () { - let mouseNavigate: ((event: unknown, payload: unknown) => void) | undefined; - const goBack = vi.fn(); - const goForward = vi.fn(); - let canGoBack = true; - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { - on: vi.fn((channel: string, listener: typeof mouseNavigate) => { - if (channel === "preview:mouse-navigate") mouseNavigate = listener; - }), - off: vi.fn(), - }, - send: webviewSend, - navigationHistory: { - canGoBack: () => canGoBack, - canGoForward: () => true, - goBack, - goForward, - }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never); - - yield* manager.createTab("tab_nav"); - yield* manager.registerWebview("tab_nav", 42); - expect(mouseNavigate).toBeDefined(); - - mouseNavigate?.({}, { direction: "back" }); - yield* Effect.yieldNow; - expect(goBack).toHaveBeenCalledOnce(); - - mouseNavigate?.({}, { direction: "forward" }); - yield* Effect.yieldNow; - expect(goForward).toHaveBeenCalledOnce(); - - mouseNavigate?.({}, { direction: "sideways" }); - canGoBack = false; - mouseNavigate?.({}, { direction: "back" }); - yield* Effect.yieldNow; - expect(goBack).toHaveBeenCalledOnce(); + const { result, crop } = yield* captureAnnotation(cropRect, { + elements: [ + { + id: "element_1", + rect, + element: { + pageUrl: "https://example.com", + pageTitle: "Example", + tagName: "line", + selector: "svg > line", + htmlPreview: '', + componentName: null, + source: null, + stack: [], + styles: "stroke: black; stroke-width: 2px;", + pickedAt: "2026-06-11T00:00:00.000Z", + }, + }, + ], + }); + expect(crop).toHaveBeenCalledExactlyOnceWith(expectedCrop); + expect(result?.screenshotFailed).toBeUndefined(); + expect(result?.annotation.screenshot).toMatchObject({ + dataUrl: "data:image/png;base64,cropped", + width: expectedCrop.width, + height: expectedCrop.height, + }); }), - ), - ); + ); + }); effectIt.effect("reveals only files inside the configured browser artifact directory", () => withManager((manager) => @@ -4586,71 +2564,4 @@ describe("PreviewManager", () => { }), ), ); - - effectIt.effect("copies screenshot artifacts to the system clipboard", () => - withManager((manager) => - Effect.gen(function* () { - const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"; - - yield* manager.copyArtifactToClipboard(artifactPath); - - expect(createFromPath).toHaveBeenCalledWith(artifactPath); - expect(clipboardItemConstructor).toHaveBeenCalledWith({ - "image/png": expect.any(Blob), - }); - expect(writeClipboard).toHaveBeenCalledOnce(); - const exit = yield* Effect.exit( - manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json"), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isSuccess(exit)) return; - const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); - expect(error).toMatchObject({ - _tag: "PreviewArtifactPathOutsideDirectoryError", - artifactPath: "/tmp/t3/dev/settings.json", - artifactDirectory: "/tmp/t3/dev/browser-artifacts", - }); - expect("cause" in error).toBe(false); - - createFromPath.mockReturnValueOnce({ - isEmpty: () => true, - toPNG: () => Buffer.from("invalid"), - }); - const invalidImageExit = yield* Effect.exit(manager.copyArtifactToClipboard(artifactPath)); - expect(Exit.isFailure(invalidImageExit)).toBe(true); - if (Exit.isSuccess(invalidImageExit)) return; - expect(Option.getOrThrow(Cause.findErrorOption(invalidImageExit.cause))).toMatchObject({ - _tag: "PreviewArtifactImageLoadError", - artifactPath, - }); - - const writeCause = new Error("clipboard write failed"); - writeClipboard.mockRejectedValueOnce(writeCause); - const writeExit = yield* Effect.exit(manager.copyArtifactToClipboard(artifactPath)); - expect(Exit.isFailure(writeExit)).toBe(true); - if (Exit.isSuccess(writeExit)) return; - expect(Option.getOrThrow(Cause.findErrorOption(writeExit.cause))).toMatchObject({ - _tag: "PreviewOperationError", - operation: "copyArtifactToClipboard.write", - artifactPath, - cause: writeCause, - }); - }), - ), - ); -}); - -describe("PreviewOperationError", () => { - it("keeps timeline detail separate from its structured message", () => { - const cause = new Error("CDP command failed with an invalid node id"); - const error = new PreviewManager.PreviewOperationError({ - operation: "click.DOM.resolveNode", - tabId: "tab_1", - webContentsId: 42, - cause, - }); - - expect(error.message).not.toContain(cause.message); - expect(PreviewManager.PreviewOperationError.toTimelineMessage(error)).toBe(cause.message); - }); }); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 56b9d960369c..e9578c711ae4 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -206,7 +206,7 @@ export const fitPictureInPictureContentSize = ( return [Math.round(width), Math.round(height)]; }; -export const recordingFileExtension = (mimeType: string): string => { +const recordingFileExtension = (mimeType: string): string => { const subtype = mimeType.split(";", 1)[0]?.trim().toLowerCase().split("/")[1] ?? ""; const extension = subtype.replace(/^x-/, "").replace(/[^a-z0-9]/g, ""); return extension || "video"; @@ -270,24 +270,35 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { ) { return null; } - return { - x: Math.max(0, Math.floor(x)), - y: Math.max(0, Math.floor(y)), - width: Math.max(1, Math.ceil(width)), - height: Math.max(1, Math.ceil(height)), - }; + return { x, y, width, height }; }; -/** - * Crops a full-page capture to a picked annotation. `cropRect` is in CSS px; - * the capture's pixels are CSS px × the guest's `devicePixelRatio`. - */ +// capturePage returns a 1x NativeImage containing device pixels, so crop() uses +// CSS coordinates scaled by the guest's devicePixelRatio (including page zoom). const cropAnnotationScreenshot = ( image: Electron.NativeImage, cropRect: PreviewAnnotationRect | null, devicePixelRatio: number, + targetRects: ReadonlyArray, ): PreviewAnnotationPayload["screenshot"] => { const full = image.getSize(); + if (full.width <= 0 || full.height <= 0) return null; + const scale = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1; + // Padding or a union of offscreen targets can overlap an empty viewport area. + // A zero-width/height SVG line can still paint at the top or left edge. + if ( + targetRects.length > 0 && + !targetRects.some( + (rect) => + (rect.width > 0 || rect.height > 0) && + rect.x * scale < full.width && + rect.y * scale < full.height && + (rect.width === 0 ? rect.x >= 0 : rect.x + rect.width > 0) && + (rect.height === 0 ? rect.y >= 0 : rect.y + rect.height > 0), + ) + ) { + return null; + } if (!cropRect) { return { dataUrl: image.toDataURL(), @@ -296,16 +307,21 @@ const cropAnnotationScreenshot = ( cropRect: { x: 0, y: 0, width: full.width, height: full.height }, }; } - const scale = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1; - const x = Math.min(full.width - 1, Math.max(0, Math.floor(cropRect.x * scale))); - const y = Math.min(full.height - 1, Math.max(0, Math.floor(cropRect.y * scale))); + const x = Math.max(0, Math.floor(cropRect.x * scale)); + const y = Math.max(0, Math.floor(cropRect.y * scale)); + const right = Math.min(full.width, Math.ceil((cropRect.x + cropRect.width) * scale)); + const bottom = Math.min(full.height, Math.ceil((cropRect.y + cropRect.height) * scale)); + // A target scrolled out of view has no pixels in this capture. Clamping its + // origin onto the edge would attach an unrelated strip as a successful crop. + if (right <= x || bottom <= y) return null; const cropped = image.crop({ x, y, - width: Math.max(1, Math.min(full.width - x, Math.ceil(cropRect.width * scale))), - height: Math.max(1, Math.min(full.height - y, Math.ceil(cropRect.height * scale))), + width: right - x, + height: bottom - y, }); const size = cropped.getSize(); + if (size.width <= 0 || size.height <= 0) return null; return { dataUrl: cropped.toDataURL(), width: size.width, height: size.height, cropRect }; }; @@ -479,7 +495,7 @@ export const previewWindowOpenAction = (details: { : "navigate"; }; -export const isPreviewRefreshShortcut = (input: Electron.Input): boolean => +const isPreviewRefreshShortcut = (input: Electron.Input): boolean => input.type === "keyDown" && input.key.toLowerCase() === "r" && (input.meta || input.control) && @@ -1648,6 +1664,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => Effect.gen(function* () { + // Cleanup or lock acquisition can outlive the replacement window. + if (window.isDestroyed()) return [undefined, sessions] as const; if (sessions.size > 0) { yield* setWindowBackgroundThrottling(window, false); } @@ -2220,13 +2238,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const submission = args[2] === "send" && annotationSendEnabled.get(tabId) === true ? "send" : "attach"; const devicePixelRatio = typeof args[3] === "number" ? args[3] : 1; + const targetRects = [ + ...payload.elements.map((target) => target.rect), + ...payload.regions.map((target) => target.rect), + ...payload.strokes.map((target) => target.bounds), + ]; runFork( capturePageWithRetry( { operation: "captureAnnotationScreenshot", tabId, webContentsId: wc.id }, tabId, wc, ).pipe( - Effect.map((image) => cropAnnotationScreenshot(image, cropRect, devicePixelRatio)), + Effect.map((image) => + cropAnnotationScreenshot(image, cropRect, devicePixelRatio, targetRects), + ), Effect.tapError((error) => Effect.logWarning("preview annotation screenshot failed").pipe( Effect.annotateLogs({ tabId, webContentsId: wc.id, error: error.message }), @@ -2350,8 +2375,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function // The scheme is read after the session attaches so a concurrent setColorScheme is not // overwritten with a stale snapshot. - const restoreControlSession = (tabId: string, wc: Electron.WebContents) => - Effect.gen(function* () { + const restoreControlSession = Effect.fn("PreviewManager.restoreControlSession")( + function* (tabId: string, wc: Electron.WebContents) { const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (beforeAttach?.webContentsId !== wc.id) return; const control = yield* ensureControlSession(wc); @@ -2376,7 +2401,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); } - }).pipe(Effect.ignore); + }, + (effect, _tabId, _wc) => effect.pipe(Effect.ignore), + ); // Only the session that saw the detach is replaced: by the time the event is // handled the guest may already hold a newer one, which must stay. diff --git a/apps/desktop/src/preview/PasskeyBridge.test.ts b/apps/desktop/src/preview/PasskeyBridge.test.ts index 8cbaad7f38dc..3b72ab589075 100644 --- a/apps/desktop/src/preview/PasskeyBridge.test.ts +++ b/apps/desktop/src/preview/PasskeyBridge.test.ts @@ -16,17 +16,6 @@ const assertionData = { extensions: { prf: { results: { first: base64Url([9]) } } }, }; -const attestationData = { - credentialId: base64Url([1, 2, 3]), - clientDataJSON: base64Url([4]), - attestationObject: base64Url([10]), - authData: base64Url([11]), - publicKey: base64Url([12]), - publicKeyAlgorithm: -7, - transports: [], - extensions: { credProps: { rk: true } }, -}; - const creationOptions: PublicKeyCredentialCreationOptions = { challenge: new Uint8Array([1]), rp: { name: "Example" }, @@ -144,62 +133,6 @@ describe("installPasskeyBridge", () => { }); }); - it("returns registrations with their attestation and public key", async () => { - const { invoke, credentials, FakeAttestationResponse } = install(() => ({ - success: true, - data: attestationData, - })); - - const credential = (await credentials.create({ - publicKey: creationOptions, - })) as PublicKeyCredential; - - assert.strictEqual(invoke.mock.calls[0]?.[0], PASSKEY_CREATE_CHANNEL); - const response = credential.response as AuthenticatorAttestationResponse; - assert.instanceOf(response, FakeAttestationResponse); - assert.isNull(credential.authenticatorAttachment); - assert.deepStrictEqual(bytesOf(response.attestationObject), [10]); - assert.deepStrictEqual(bytesOf(response.getAuthenticatorData()), [11]); - assert.deepStrictEqual(bytesOf(response.getPublicKey()), [12]); - assert.strictEqual(response.getPublicKeyAlgorithm(), -7); - assert.deepStrictEqual(credential.getClientExtensionResults(), { credProps: { rk: true } }); - assert.deepStrictEqual(credential.toJSON(), { - id: attestationData.credentialId, - rawId: attestationData.credentialId, - type: "public-key", - response: { - clientDataJSON: attestationData.clientDataJSON, - attestationObject: attestationData.attestationObject, - authenticatorData: attestationData.authData, - transports: [], - publicKey: attestationData.publicKey, - publicKeyAlgorithm: -7, - }, - clientExtensionResults: { credProps: { rk: true } }, - }); - }); - - it("leaves autofill and non-passkey requests to Chromium", async () => { - const { invoke, credentials, nativeCreate, nativeGet } = install(() => ({ - success: true, - data: assertionData, - })); - - await credentials.get({ - mediation: "conditional", - publicKey: { challenge: new Uint8Array([1]) }, - }); - await credentials.get({ password: true } as CredentialRequestOptions); - const upgrade = { mediation: "conditional", publicKey: creationOptions } as const; - await credentials.create(upgrade); - await credentials.create({ password: {} } as CredentialCreationOptions); - assert.strictEqual(invoke.mock.calls.length, 0); - assert.strictEqual(nativeGet.mock.calls.length, 2); - assert.strictEqual(nativeCreate.mock.calls.length, 2); - assert.isFalse(await PublicKeyCredential.isConditionalMediationAvailable()); - assert.isTrue(await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()); - }); - it("rejects with the WebAuthn error the ceremony ended with", async () => { const { credentials } = install((channel) => ({ success: false, @@ -216,18 +149,6 @@ describe("installPasskeyBridge", () => { assert.instanceOf(fetched, TypeError); }); - it("refuses when the main process has no handler for the page", async () => { - const { credentials } = install(() => { - throw new Error("Error invoking remote method 'preview:passkey-get': No handler registered"); - }); - - const error = await credentials - .get({ publicKey: { challenge: new Uint8Array([1]) } }) - .catch((caught: unknown) => caught); - assert.instanceOf(error, DOMException); - assert.strictEqual((error as DOMException).name, "NotAllowedError"); - }); - it("rejects as soon as the page aborts, without waiting on the system sheet", async () => { const { credentials } = install(() => new Promise(() => {})); const controller = new AbortController(); diff --git a/apps/desktop/src/preview/Passkeys.test.ts b/apps/desktop/src/preview/Passkeys.test.ts index c6b8720314a3..f848aff47b8f 100644 --- a/apps/desktop/src/preview/Passkeys.test.ts +++ b/apps/desktop/src/preview/Passkeys.test.ts @@ -257,9 +257,13 @@ describe("PreviewPasskeys", () => { page.state.focused = true; let finish: (value: unknown) => void = () => {}; - webauthn.getCredential.mockReturnValueOnce(new Promise((resolve) => (finish = resolve))); + const ceremonyStarted = Promise.withResolvers(); + webauthn.getCredential.mockImplementationOnce(() => { + ceremonyStarted.resolve(); + return new Promise((resolve) => (finish = resolve)); + }); const first = yield* Effect.forkChild(page.call(PASSKEY_GET_CHANNEL, publicKey)); - yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + yield* Effect.promise(() => ceremonyStarted.promise); assert.deepStrictEqual(yield* page.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); page.mainFrame.origin = "https://other.example"; @@ -268,26 +272,6 @@ describe("PreviewPasskeys", () => { }).pipe(Effect.provide(bridgeLayer)), ); - it.effect("answers the page even when the native ceremony never settles", () => - Effect.gen(function* () { - const passkeys = yield* PreviewPasskeys.PreviewPasskeys; - const { guest, call } = makeGuest(); - passkeys.attachGuest(guest); - webauthn.getCredential.mockReturnValueOnce(new Promise(() => {})); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - - const pending = yield* Effect.forkChild( - call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]), timeout: 1_000 }), - ); - yield* Effect.promise(() => vi.advanceTimersByTimeAsync(6_000)); - - assert.deepStrictEqual(yield* Fiber.join(pending), { - success: false, - error: "NotAllowedError", - }); - }).pipe(Effect.provide(bridgeLayer)), - ); - it.effect("turns each path on only for builds signed for it", () => Effect.gen(function* () { const entitled = yield* PreviewPasskeys.PreviewPasskeys.pipe( diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index a1df630de4e9..e49d27d40ba9 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -252,15 +252,12 @@ function unionRects( const top = Math.min(...rects.map((rect) => rect.y)); const right = Math.max(...rects.map((rect) => rect.x + rect.width)); const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); - const x = Math.max(0, left - padding); - const y = Math.max(0, top - padding); - const maxWidth = Math.max(1, window.innerWidth - x); - const maxHeight = Math.max(1, window.innerHeight - y); + // Main clips to the captured viewport; clipping here would lose offscreen bounds. return { - x, - y, - width: Math.min(maxWidth, right - left + padding * 2), - height: Math.min(maxHeight, bottom - top + padding * 2), + x: left - padding, + y: top - padding, + width: right - left + padding * 2, + height: bottom - top + padding * 2, }; } @@ -1439,7 +1436,7 @@ function startAnnotation(sendEnabled: boolean): void { annotation, screenshotRect, submission === "send" && !sendEnabled ? "attach" : submission, - // Main crops a full-page capture, whose pixels are CSS px × this. + // Main crops a viewport capture, whose pixels are CSS px × this. window.devicePixelRatio, ); }) diff --git a/apps/desktop/src/preview/PickedElementPayload.test.ts b/apps/desktop/src/preview/PickedElementPayload.test.ts index d7a967324771..68f313783a27 100644 --- a/apps/desktop/src/preview/PickedElementPayload.test.ts +++ b/apps/desktop/src/preview/PickedElementPayload.test.ts @@ -31,76 +31,6 @@ function validPayload(overrides?: Record): Record { - it("accepts a complete, well-typed payload", () => { - expect(isPickedElementPayload(validPayload())).toBe(true); - }); - - it("accepts nullable string fields when null", () => { - expect( - isPickedElementPayload( - validPayload({ pageTitle: null, selector: null, componentName: null, source: null }), - ), - ).toBe(true); - }); - - it("accepts an empty stack array", () => { - expect(isPickedElementPayload(validPayload({ stack: [] }))).toBe(true); - }); - - it("accepts stack frames with null fields", () => { - expect( - isPickedElementPayload( - validPayload({ - stack: [ - { - functionName: null, - fileName: null, - lineNumber: null, - columnNumber: null, - }, - ], - }), - ), - ).toBe(true); - }); - - it("rejects null and primitive inputs", () => { - expect(isPickedElementPayload(null)).toBe(false); - expect(isPickedElementPayload(undefined)).toBe(false); - expect(isPickedElementPayload("string")).toBe(false); - expect(isPickedElementPayload(42)).toBe(false); - expect(isPickedElementPayload([])).toBe(false); - }); - - it.each<[string, Record]>([ - ["missing pageUrl", validPayload({ pageUrl: undefined })], - ["wrong-type pageUrl", validPayload({ pageUrl: 123 })], - ["missing tagName", validPayload({ tagName: undefined })], - ["missing htmlPreview", validPayload({ htmlPreview: undefined })], - ["missing styles", validPayload({ styles: undefined })], - ["missing pickedAt", validPayload({ pickedAt: undefined })], - ["wrong-type pageTitle", validPayload({ pageTitle: 99 })], - ["wrong-type selector", validPayload({ selector: 99 })], - ["wrong-type componentName", validPayload({ componentName: 99 })], - ])("rejects payloads with %s", (_label, value) => { - expect(isPickedElementPayload(value)).toBe(false); - }); - - it("rejects malformed source frames", () => { - expect( - isPickedElementPayload( - validPayload({ - source: { - functionName: 0, - fileName: null, - lineNumber: null, - columnNumber: null, - }, - }), - ), - ).toBe(false); - }); - it("rejects non-finite numeric line/column numbers", () => { expect( isPickedElementPayload( @@ -127,11 +57,6 @@ describe("isPickedElementPayload", () => { ), ).toBe(false); }); - - it("rejects malformed stack arrays", () => { - expect(isPickedElementPayload(validPayload({ stack: "not-an-array" }))).toBe(false); - expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false); - }); }); function validAnnotation(overrides?: Record): Record { @@ -176,26 +101,9 @@ function validAnnotation(overrides?: Record): Record { - it("accepts a structured annotation draft before screenshot capture", () => { - expect(isPreviewAnnotationPayload(validAnnotation())).toBe(true); - }); - it("rejects screenshots supplied by the guest preload", () => { expect(isPreviewAnnotationPayload(validAnnotation({ screenshot: { dataUrl: "bad" } }))).toBe( false, ); }); - - it("rejects malformed geometry and nested element payloads", () => { - expect( - isPreviewAnnotationPayload( - validAnnotation({ regions: [{ id: "region_1", rect: { x: 0, y: 0, width: "wide" } }] }), - ), - ).toBe(false); - expect( - isPreviewAnnotationPayload( - validAnnotation({ elements: [{ id: "element_1", element: {}, rect: {} }] }), - ), - ).toBe(false); - }); }); diff --git a/apps/desktop/src/preview/RecordingInput.test.ts b/apps/desktop/src/preview/RecordingInput.test.ts index 062685ec2934..0f4f93fb36bf 100644 --- a/apps/desktop/src/preview/RecordingInput.test.ts +++ b/apps/desktop/src/preview/RecordingInput.test.ts @@ -1,39 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { recordingKeyLabel, recordingKeysAreSensitive } from "./RecordingInput.ts"; - -const key = ( - value: string, - modifiers: Partial<{ - metaKey: boolean; - ctrlKey: boolean; - altKey: boolean; - shiftKey: boolean; - }> = {}, -) => ({ - key: value, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - ...modifiers, -}); - -describe("recording key labels", () => { - it("formats macOS and other-platform shortcuts", () => { - expect(recordingKeyLabel(key("c", { metaKey: true }), true)).toBe("⌘C"); - expect(recordingKeyLabel(key("c", { ctrlKey: true }), false)).toBe("Ctrl + C"); - expect(recordingKeyLabel(key("Tab", { altKey: true, shiftKey: true }), true)).toBe("⌥⇧⇥"); - }); - it("shows held modifiers once and labels navigation keys", () => { - expect(recordingKeyLabel(key("Meta", { metaKey: true }), true)).toBe("⌘"); - expect(recordingKeyLabel(key("Shift", { shiftKey: true }), false)).toBe("Shift"); - expect(recordingKeyLabel(key("ArrowLeft"), true)).toBe("←"); - expect(recordingKeyLabel(key(" "), false)).toBe("Space"); - }); - it.each(["Dead", "Unidentified", "Process", ""])("excludes composition key %s", (value) => { - expect(recordingKeyLabel(key(value), true)).toBeNull(); - }); -}); +import { recordingKeysAreSensitive } from "./RecordingInput.ts"; describe("recording key privacy", () => { const field = (type: string) => ({ tagName: "INPUT", getAttribute: () => type }); diff --git a/apps/desktop/src/preview/WebviewPreferences.test.ts b/apps/desktop/src/preview/WebviewPreferences.test.ts index 94681b3e046f..182b6a505d16 100644 --- a/apps/desktop/src/preview/WebviewPreferences.test.ts +++ b/apps/desktop/src/preview/WebviewPreferences.test.ts @@ -20,12 +20,6 @@ function parseWebPreferences(input: string): Record { describe("PREVIEW_WEBVIEW_PREFERENCES", () => { const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES); - it("contains exactly the three security-critical keys", () => { - expect(Object.keys(parsed).toSorted()).toEqual( - ["contextIsolation", "nodeIntegration", "sandbox"].toSorted(), - ); - }); - it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => { // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so // `contextIsolation="no"` would silently leave isolation ENABLED. Lock @@ -35,19 +29,7 @@ describe("PREVIEW_WEBVIEW_PREFERENCES", () => { } }); - it("disables context isolation (so react-grab can see the page's React DevTools hook)", () => { - expect(parsed["contextIsolation"]).toBe("false"); - }); - it("keeps the renderer sandbox enabled (so the page cannot reach Node APIs)", () => { expect(parsed["sandbox"]).toBe("true"); }); - - it("disables nodeIntegration (defense in depth — page never gets Node)", () => { - expect(parsed["nodeIntegration"]).toBe("false"); - }); - - it("contains no whitespace (Electron's parser does not trim)", () => { - expect(PREVIEW_WEBVIEW_PREFERENCES).not.toMatch(/\s/); - }); }); diff --git a/apps/desktop/src/preview/captureErrors.ts b/apps/desktop/src/preview/captureErrors.ts new file mode 100644 index 000000000000..1284cc7d99b9 --- /dev/null +++ b/apps/desktop/src/preview/captureErrors.ts @@ -0,0 +1,38 @@ +import { PreviewCaptureFailure } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import type { PreviewManagerError } from "./Manager.ts"; + +/** Classify before Electron replaces a rejected IPC Error with its message. */ +export function normalizeCaptureFailure( + error: PreviewManagerError, + stage: PreviewCaptureFailure["stage"], +): PreviewCaptureFailure { + let reason: PreviewCaptureFailure["reason"] = "capture-failed"; + switch (error._tag) { + case "PreviewTabNotFoundError": + case "PreviewWebContentsNotFoundError": + case "PreviewMainWindowClosedError": + reason = "renderer-unavailable"; + break; + case "PreviewWebviewNotInitializedError": + case "PreviewRecordingCaptureUnavailableError": + reason = "capture-unavailable"; + break; + case "PreviewRecordingArmConflictError": + reason = "recording-conflict"; + break; + case "PreviewOperationError": + if (error.operation === "captureScreenshot.capturePage") { + stage = "capture-page"; + if ( + error.cause instanceof Error && + error.cause.message === "Current display surface not available for capture" + ) { + reason = "capture-unavailable"; + } + } + if (Cause.isTimeoutError(error.cause)) reason = "capture-timeout"; + break; + } + return new PreviewCaptureFailure({ reason, stage }); +} diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index a98aa96079e6..832a77457714 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -3,7 +3,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as DesktopConfig from "../app/DesktopConfig.ts"; @@ -109,124 +108,6 @@ describe("DesktopSettings", () => { }), ), ); - it.effect("loads defaults when no settings file exists", () => - withSettings( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - assert.deepEqual(yield* settings.load, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS); - assert.deepEqual(yield* settings.get, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS); - }), - ), - ); - - it("defaults packaged nightly builds to the nightly update channel", () => { - assert.deepEqual( - DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), - { - linuxPasswordStore: "auto", - localEnvironmentEnabled: true, - mainWindowBounds: null, - mainWindowMaximized: false, - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: false, - wslBackendEnabled: false, - wslOnly: false, - wslDistro: null, - } satisfies DesktopAppSettings.DesktopSettings, - ); - }); - - it.effect("loads persisted settings and applies semantic updates", () => - withSettings( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* writeSettingsPatch({ - linuxPasswordStore: "gnome-libsecret", - serverExposureMode: "network-accessible", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, - }); - - assert.deepEqual(yield* settings.load, { - linuxPasswordStore: "gnome-libsecret", - localEnvironmentEnabled: true, - mainWindowBounds: null, - mainWindowMaximized: false, - serverExposureMode: "network-accessible", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, - wslBackendEnabled: false, - wslOnly: false, - wslDistro: null, - } satisfies DesktopAppSettings.DesktopSettings); - - const exposure = yield* settings.setServerExposureMode("local-only"); - assert.isTrue(exposure.changed); - assert.equal(exposure.settings.serverExposureMode, "local-only"); - - const tailscale = yield* settings.setTailscaleServe({ - enabled: true, - port: Option.some(9443), - }); - assert.isTrue(tailscale.changed); - assert.equal(tailscale.settings.tailscaleServePort, 9443); - - const updateChannel = yield* settings.setUpdateChannel("nightly"); - assert.isTrue(updateChannel.changed); - assert.equal(updateChannel.settings.updateChannel, "nightly"); - assert.equal(updateChannel.settings.updateChannelConfiguredByUser, true); - }), - ), - ); - - it.effect("reports the failed desktop settings write operation and path", () => - withSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* fileSystem.makeDirectory(environment.desktopSettingsPath, { recursive: true }); - - const error = yield* settings.setServerExposureMode("network-accessible").pipe(Effect.flip); - assert.instanceOf(error, DesktopAppSettings.DesktopSettingsWriteError); - assert.equal(error.operation, "replace-settings-file"); - assert.equal(error.path, environment.desktopSettingsPath); - assert.exists(error.cause); - assert.equal( - error.message, - `Desktop settings write failed during replace-settings-file at ${environment.desktopSettingsPath}.`, - ); - }), - ), - ); - - it.effect("does not persist no-op semantic updates", () => - withSettings( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - - const exposure = yield* settings.setServerExposureMode("local-only"); - assert.isFalse(exposure.changed); - - const tailscale = yield* settings.setTailscaleServe({ - enabled: false, - port: Option.none(), - }); - assert.isFalse(tailscale.changed); - - const updateChannel = yield* settings.setUpdateChannel("latest"); - assert.isFalse(updateChannel.changed); - assert.equal(updateChannel.settings.updateChannelConfiguredByUser, false); - }), - ), - ); it.effect("falls back to defaults when the settings file is malformed", () => withSettings( @@ -296,45 +177,6 @@ describe("DesktopSettings", () => { ), ); - it.effect( - "normalizes unsupported linux password-store values without dropping other settings", - () => - withSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString( - environment.desktopSettingsPath, - `{ - "linuxPasswordStore": "unsupported-store", - "serverExposureMode": "network-accessible", - "tailscaleServeEnabled": true, - "tailscaleServePort": 8443, - "updateChannel": "nightly", - "updateChannelConfiguredByUser": true - }\n`, - ); - - assert.deepEqual(yield* settings.load, { - linuxPasswordStore: "auto", - localEnvironmentEnabled: true, - mainWindowBounds: null, - mainWindowMaximized: false, - serverExposureMode: "network-accessible", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "nightly", - updateChannelConfiguredByUser: true, - wslBackendEnabled: false, - wslOnly: false, - wslDistro: null, - } satisfies DesktopAppSettings.DesktopSettings); - }), - ), - ); - it.effect("persists sparse desktop settings documents", () => withSettings( Effect.gen(function* () { @@ -442,33 +284,6 @@ describe("DesktopSettings", () => { ), ); - it.effect("normalizes invalid persisted Tailscale Serve ports", () => - withSettings( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* writeSettingsPatch({ - tailscaleServeEnabled: true, - tailscaleServePort: 0, - }); - - assert.deepEqual(yield* settings.load, { - linuxPasswordStore: "auto", - localEnvironmentEnabled: true, - mainWindowBounds: null, - mainWindowMaximized: false, - serverExposureMode: "local-only", - tailscaleServeEnabled: true, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - wslBackendEnabled: false, - wslOnly: false, - wslDistro: null, - } satisfies DesktopAppSettings.DesktopSettings); - }), - ), - ); - it.effect("persists wsl backend toggle and normalizes invalid distro names", () => withSettings( Effect.gen(function* () { @@ -543,19 +358,4 @@ describe("DesktopSettings", () => { }), ), ); - - it.effect("drops invalid persisted wsl distro values on load", () => - withSettings( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* writeSettingsPatch({ - wslBackendEnabled: true, - wslDistro: "bad/name", - }); - const loaded = yield* settings.load; - assert.equal(loaded.wslBackendEnabled, true); - assert.equal(loaded.wslDistro, null); - }), - ), - ); }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts deleted file mode 100644 index 41ba53cf1d43..000000000000 --- a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; -import * as Option from "effect/Option"; -import * as PlatformError from "effect/PlatformError"; -import * as References from "effect/References"; - -import * as DesktopConfig from "../app/DesktopConfig.ts"; -import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as DesktopClientSettings from "./DesktopClientSettings.ts"; - -interface LogRecord { - readonly message: unknown; - readonly annotations: Readonly>; -} - -const baseDir = "/virtual-home"; - -function layer(fileSystemLayer: Layer.Layer) { - const layerEnvironment = DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop/src", - homeDirectory: baseDir, - platform: "darwin", - processArch: "x64", - appVersion: "1.2.3", - appPath: "/repo", - isPackaged: true, - resourcesPath: "/missing/resources", - runningUnderArm64Translation: false, - }).pipe( - Layer.provide( - Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), - ), - ); - - return DesktopClientSettings.layer.pipe( - Layer.provideMerge(Layer.mergeAll(layerEnvironment, NodeServices.layer, fileSystemLayer)), - ); -} - -const readWithLogs = (fileSystemLayer: Layer.Layer) => { - const records: Array = []; - const logger = Logger.make(({ fiber, message }) => { - records.push({ - message, - annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, - }); - }); - - return Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - return { - result: yield* Effect.result(settings.get), - settingsPath: environment.clientSettingsPath, - records, - }; - }).pipe( - Effect.provide( - Layer.mergeAll(layer(fileSystemLayer), Logger.layer([logger], { mergeWithExisting: false })), - ), - ); -}; - -describe("DesktopClientSettings diagnostics", () => { - it.effect("treats a missing settings file as expected without warning", () => - Effect.gen(function* () { - const result = yield* readWithLogs(FileSystem.layerNoop({})); - - if (result.result._tag !== "Success") return assert.fail("expected a successful read"); - assert.isTrue(Option.isNone(result.result.success)); - assert.deepEqual(result.records, []); - }), - ); - - it.effect("reports non-missing filesystem failures and logs the settings path", () => { - const permissionError = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: `${baseDir}/userdata/client-settings.json`, - }); - - return Effect.gen(function* () { - const result = yield* readWithLogs( - FileSystem.layerNoop({ - readFileString: () => Effect.fail(permissionError), - }), - ); - - if (result.result._tag !== "Failure") return assert.fail("expected a read failure"); - assert.instanceOf( - result.result.failure, - DesktopClientSettings.DesktopClientSettingsReadError, - ); - assert.strictEqual(result.result.failure.cause, permissionError); - assert.equal(result.records.length, 1); - assert.deepEqual(result.records[0]?.message, [ - "Could not read desktop client settings.", - permissionError, - ]); - assert.equal(result.records[0]?.annotations.settingsPath, result.settingsPath); - }); - }); - - it.effect("reports malformed settings documents and logs the settings path", () => - Effect.gen(function* () { - const result = yield* readWithLogs( - FileSystem.layerNoop({ - readFileString: () => Effect.succeed("{not-json"), - }), - ); - - if (result.result._tag !== "Failure") return assert.fail("expected a decode failure"); - assert.instanceOf( - result.result.failure, - DesktopClientSettings.DesktopClientSettingsReadError, - ); - assert.equal(result.result.failure.operation, "decode-document"); - assert.equal(result.records.length, 1); - const message = result.records[0]?.message; - if (!Array.isArray(message)) { - return assert.fail("expected structured warning arguments"); - } - assert.equal(message[0], "Could not decode desktop client settings."); - const schemaError = message[1]; - if (schemaError === null || typeof schemaError !== "object") { - return assert.fail("expected the schema error in the warning"); - } - assert.equal("_tag" in schemaError ? schemaError._tag : undefined, "SchemaError"); - assert.equal(result.records[0]?.annotations.settingsPath, result.settingsPath); - }), - ); -}); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 75ff38f32260..51c3d31daae8 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -75,9 +75,6 @@ const clientSettings: ClientSettings = { }; const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema)); -const decodeRecordJson = Schema.decodeEffect( - Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), -); function layer(baseDir: string) { const layerEnvironment = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", @@ -113,42 +110,6 @@ const withClientSettings = ( }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); describe("DesktopClientSettings", () => { - it.effect("returns none when no client settings file exists", () => - withClientSettings( - Effect.gen(function* () { - const settings = yield* DesktopClientSettings.DesktopClientSettings; - assert.isTrue(Option.isNone(yield* settings.get)); - }), - ), - ); - - it.effect("persists and reloads client settings", () => - withClientSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - yield* settings.set(clientSettings); - - assert.deepEqual(yield* settings.get, Option.some(clientSettings)); - assert.deepEqual( - yield* decodeClientSettingsJson( - yield* fileSystem.readFileString(environment.clientSettingsPath), - ), - clientSettings, - ); - assert.isFalse( - Object.hasOwn( - yield* decodeRecordJson( - yield* fileSystem.readFileString(environment.clientSettingsPath), - ), - "settings", - ), - ); - }), - ), - ); - it.effect("saves through a symlinked client settings file without replacing the link", () => withClientSettings( Effect.gen(function* () { @@ -228,29 +189,6 @@ describe("DesktopClientSettings", () => { ), ); - it.effect("reports the failed client settings write operation and path", () => - withClientSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - yield* fileSystem.makeDirectory(environment.clientSettingsPath, { recursive: true }); - - const error = yield* settings.set(clientSettings).pipe(Effect.flip); - assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsWriteError); - assert.equal(error.operation, "replace-settings-file"); - assert.equal(error.path, environment.clientSettingsPath); - assert.instanceOf(error.cause, PlatformError.PlatformError); - assert.isString(error.cause.stack); - assert.equal( - error.message, - `Desktop client settings write failed during replace-settings-file at ${environment.clientSettingsPath}.`, - ); - assert.notInclude(error.message, error.cause.message); - }), - ), - ); - it.effect("loads lenient direct client settings documents", () => withClientSettings( Effect.gen(function* () { @@ -300,23 +238,8 @@ describe("DesktopClientSettings", () => { ), ); - it.effect("loads defaults from empty client settings documents", () => - withClientSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.clientSettingsPath, "{}\n"); - - assert.deepEqual(yield* settings.get, Option.some(yield* decodeClientSettingsJson("{}"))); - }), - ), - ); - it.effect.each([ { label: "malformed JSON", contents: "{not-json" }, - { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' }, { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' }, ])("reports $label without treating the settings file as absent", (document) => withClientSettings( diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index 65c7f609318c..ed25bcf301ec 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -4,10 +4,8 @@ import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/co import * as Effect from "effect/Effect"; import * as Base64 from "effect/encoding/Base64"; import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as DesktopConfig from "../app/DesktopConfig.ts"; @@ -156,17 +154,6 @@ const withSavedEnvironments = ( }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); describe("DesktopSavedEnvironments", () => { - it.effect("reads saved environment metadata", () => - withSavedEnvironments( - Effect.gen(function* () { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* seedSavedEnvironmentRegistry(); - - assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); - }), - ), - ); - it.effect("loads lenient saved environment registry documents", () => withSavedEnvironments( Effect.gen(function* () { @@ -217,30 +204,6 @@ describe("DesktopSavedEnvironments", () => { ), ); - it.effect("reports invalid saved secret encoding without exposing the secret", () => - withSavedEnvironments( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* seedSavedEnvironmentRegistry("%%%"); - - const error = yield* savedEnvironments - .getSecret(savedRegistryRecord.environmentId) - .pipe(Effect.flip); - assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentSecretDecodeError); - assert.equal(error.environmentId, savedRegistryRecord.environmentId); - assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath); - assert.equal(error.field, "encryptedBearerToken"); - assert.exists(error.cause); - assert.equal( - error.message, - `Failed to decode encryptedBearerToken for environment ${savedRegistryRecord.environmentId} at ${environment.savedEnvironmentRegistryPath}.`, - ); - assert.notInclude(error.message, "%%%"); - }), - ), - ); - it.effect("returns no secret while encryption is unavailable", () => withSavedEnvironments( Effect.gen(function* () { @@ -255,114 +218,4 @@ describe("DesktopSavedEnvironments", () => { { availableSecretStorage: false }, ), ); - - it.effect("adds saved-environment context to safe storage availability failures", () => { - const cause = new Error("safe storage unavailable"); - return withSavedEnvironments( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* seedSavedEnvironmentRegistry(Base64.encode(textEncoder.encode("enc:bearer-token"))); - - const error = yield* savedEnvironments - .getSecret(savedRegistryRecord.environmentId) - .pipe(Effect.flip); - - assert.instanceOf( - error, - DesktopSavedEnvironments.DesktopSavedEnvironmentSecretProtectionError, - ); - assert.equal(error.operation, "check-encryption-availability"); - assert.equal(error.environmentId, savedRegistryRecord.environmentId); - assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath); - assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); - const availabilityError = - error.cause as ElectronSafeStorage.ElectronSafeStorageAvailabilityError; - assert.strictEqual(availabilityError.cause, cause); - assert.equal( - error.message, - `Desktop saved-environment secret protection failed during check-encryption-availability for environment ${savedRegistryRecord.environmentId} at ${environment.savedEnvironmentRegistryPath}.`, - ); - assert.notEqual(error.message, availabilityError.message); - }), - { availabilityError: cause }, - ); - }); - - it.effect("treats empty saved environment documents as empty", () => - withSavedEnvironments( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{}\n"); - - assert.deepEqual(yield* savedEnvironments.getRegistry, []); - assert.isTrue( - Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), - ); - }), - ), - ); - - it.effect("surfaces malformed saved environment documents", () => - withSavedEnvironments( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json"); - - const registryError = yield* savedEnvironments.getRegistry.pipe(Effect.flip); - assert.instanceOf( - registryError, - DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, - ); - assert.equal(registryError.registryPath, environment.savedEnvironmentRegistryPath); - assert.exists(registryError.cause); - const secretError = yield* savedEnvironments - .getSecret(savedRegistryRecord.environmentId) - .pipe(Effect.flip); - assert.instanceOf( - secretError, - DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, - ); - }), - ), - ); - - it.effect("reports saved environment filesystem reads separately from document decoding", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const baseFileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-saved-environments-test-", - }); - const registryPath = path.join(baseDir, "userdata", "saved-environments.json"); - const permissionError = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: registryPath, - }); - const layerFileSystem = Layer.succeed( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.fail(permissionError), - }), - ); - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments.pipe( - Effect.provide(layer(baseDir, undefined, layerFileSystem)), - ); - - const error = yield* savedEnvironments.getRegistry.pipe(Effect.flip); - assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError); - assert.equal(error.registryPath, registryPath); - assert.strictEqual(error.cause, permissionError); - assert.equal(error.message, `Failed to read desktop saved environments at ${registryPath}.`); - assert.notEqual(error.message, permissionError.message); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); }); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index aee5a0bc81b5..9d12ed5a07fe 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,10 +1,10 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The process fixture runs real shell commands with isolated config files. import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as PlatformError from "effect/PlatformError"; -import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; @@ -19,10 +19,6 @@ vi.mock("node:os", { spy: true }); const textEncoder = new TextEncoder(); -const isDesktopShellEnvironmentCommandError = Schema.is( - DesktopShellEnvironment.DesktopShellEnvironmentCommandError, -); - function envOutput(values: Readonly>): string { return Object.entries(values) .flatMap(([name, value]) => [ @@ -145,52 +141,6 @@ describe("DesktopShellEnvironment", () => { }), ); - it.effect.each(["darwin", "linux"] as const)( - "uses the account shell when SHELL is missing on %s", - (platform) => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" }; - const commands: string[] = []; - - yield* runShellEnvironment({ - env, - platform, - userShell: "/usr/local/bin/fish", - handler: (command) => { - if (command._tag !== "StandardCommand") return ""; - commands.push(command.command); - return envOutput({ PATH: "/home/test/.local/bin:/usr/bin" }); - }, - }); - - assert.deepEqual(commands, ["/usr/local/bin/fish"]); - assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin"); - }), - ); - - it.effect("tries the account shell before the platform fallback when SHELL has no PATH", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { SHELL: "/bin/missing", PATH: "/usr/bin" }; - const commands: string[] = []; - - yield* runShellEnvironment({ - env, - platform: "linux", - userShell: "/usr/local/bin/fish", - handler: (command) => { - if (command._tag !== "StandardCommand") return ""; - commands.push(command.command); - return command.command === "/usr/local/bin/fish" - ? envOutput({ PATH: "/home/test/.local/bin:/usr/bin" }) - : ""; - }, - }); - - assert.deepEqual(commands, ["/bin/missing", "/usr/local/bin/fish"]); - assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin"); - }), - ); - it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { @@ -220,72 +170,6 @@ describe("DesktopShellEnvironment", () => { }), ); - it.effect("preserves inherited POSIX values when present", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - SSH_AUTH_SOCK: "/tmp/inherited.sock", - }; - - yield* runShellEnvironment({ - env, - platform: "darwin", - handler: () => - envOutput({ - PATH: "/opt/homebrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/login-shell.sock", - }), - }); - - assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); - assert.equal(env.SSH_AUTH_SOCK, "/tmp/inherited.sock"); - }), - ); - - it.effect("hydrates the locale from the login shell on macOS", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - }; - - yield* runShellEnvironment({ - env, - platform: "darwin", - handler: () => - envOutput({ - PATH: "/opt/homebrew/bin:/usr/bin", - LANG: "de_DE.UTF-8", - }), - }); - - assert.equal(env.LANG, "de_DE.UTF-8"); - }), - ); - - it.effect("preserves an inherited locale over the login shell on macOS", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - LANG: "en_US.UTF-8", - }; - - yield* runShellEnvironment({ - env, - platform: "darwin", - handler: () => - envOutput({ - PATH: "/opt/homebrew/bin:/usr/bin", - LANG: "de_DE.UTF-8", - }), - }); - - assert.equal(env.LANG, "en_US.UTF-8"); - }), - ); - it.effect("does not mix login-shell locale categories into an inherited locale", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { @@ -328,47 +212,6 @@ describe("DesktopShellEnvironment", () => { }), ); - it.effect("does not apply the locale fallback on linux", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - }; - - yield* runShellEnvironment({ - env, - platform: "linux", - handler: () => envOutput({ PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin" }), - }); - - assert.equal(env.LANG, undefined); - }), - ); - - it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - }; - - yield* runShellEnvironment({ - env, - platform: "linux", - handler: () => - envOutput({ - PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/secretive.sock", - T3CODE_TELEMETRY_ENABLED: "false", - }), - }); - - assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin"); - assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); - assert.equal(env.T3CODE_TELEMETRY_ENABLED, "false"); - }), - ); - it.effect("hydrates missing CA trust variables from the login shell", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { @@ -556,66 +399,4 @@ describe("DesktopShellEnvironment", () => { assert.equal(env.XDG_SESSION_TYPE, "wayland"); }), ); - - it.effect("overrides stale dbus session addresses from the login shell", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - DBUS_SESSION_BUS_ADDRESS: "unix:path=/tmp/stale-bus", - }; - - yield* runShellEnvironment({ - env, - platform: "linux", - handler: () => - envOutput({ - PATH: "/usr/bin", - DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus", - }), - }); - - assert.equal(env.DBUS_SESSION_BUS_ADDRESS, "unix:path=/run/user/1000/bus"); - }), - ); - - it.effect("logs command failures with safe probe context and the exact cause", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/bash", - PATH: "/usr/bin", - }; - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "ChildProcess", - method: "spawn", - pathOrDescriptor: "/bin/bash", - }); - const messages: Array = []; - const logger = Logger.make(({ message }) => { - messages.push(message); - }); - - return runShellEnvironment({ - env, - platform: "linux", - handler: () => "", - failure: cause, - }).pipe( - Effect.andThen( - Effect.sync(() => { - const errors = messages - .flatMap((message) => (Array.isArray(message) ? message : [message])) - .filter(isDesktopShellEnvironmentCommandError); - assert.lengthOf(errors, 1); - assert.equal(errors[0]?.probe, "login-shell"); - assert.equal(errors[0]?.executable, "bash"); - assert.equal(errors[0]?.argumentCount, 2); - assert.notProperty(errors[0] ?? {}, "args"); - assert.equal(errors[0]?.cause, cause); - assert.notInclude(errors[0]?.message ?? "", cause.message); - }), - ), - Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), - ); - }); }); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 8fa8a60b2e62..3fdec7932366 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -1,4 +1,8 @@ -import { listLoginShellCandidates, setWindowsPath } from "@t3tools/shared/shell"; +import { + buildEnvironmentCaptureCommand, + listLoginShellCandidates, + setWindowsPath, +} from "@t3tools/shared/shell"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -199,17 +203,6 @@ const logShellEnvironmentCommandError = ( }), ); -const capturePosixEnvironmentCommand = (names: ReadonlyArray) => - names - .map((name) => { - return [ - `printf '%s\\n' '${startMarker(name)}'`, - `printenv ${name} || true`, - `printf '%s\\n' '${endMarker(name)}'`, - ].join("; "); - }) - .join("; "); - const captureWindowsEnvironmentCommand = (names: ReadonlyArray) => [ "$ErrorActionPreference = 'Stop'", @@ -320,7 +313,7 @@ const readLoginShellEnvironment = ( : runCommandOutput({ probe: "login-shell", command: shell, - args: ["-ilc", capturePosixEnvironmentCommand(names)], + args: ["-ilc", buildEnvironmentCaptureCommand(shell, names)], timeout: LOGIN_SHELL_TIMEOUT, }).pipe(Effect.map((output) => extractEnvironment(output, names))); diff --git a/apps/desktop/src/snapShot/ActiveWindow.test.ts b/apps/desktop/src/snapShot/ActiveWindow.test.ts deleted file mode 100644 index ddc760cbb9d0..000000000000 --- a/apps/desktop/src/snapShot/ActiveWindow.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { assert, beforeEach, it, vi } from "vite-plus/test"; - -const { execFileMock, loadWindowsForegroundApiMock } = vi.hoisted(() => ({ - execFileMock: vi.fn(), - loadWindowsForegroundApiMock: vi.fn(), -})); - -vi.mock("node:child_process", () => ({ execFile: execFileMock })); -vi.mock("../electron/WindowsForeground.ts", () => ({ - loadWindowsForegroundApi: loadWindowsForegroundApiMock, -})); - -import { activeWindow } from "./ActiveWindow.ts"; - -beforeEach(() => { - execFileMock.mockReset(); - loadWindowsForegroundApiMock.mockReset(); -}); - -function stubMacLookup(stdout: string) { - execFileMock.mockImplementation( - ( - _file: string, - _args: ReadonlyArray, - _options: unknown, - callback: (error: Error | null, stdout: string) => void, - ) => callback(null, stdout), - ); -} - -it("parses the frontmost macOS window from the osascript lookup", async () => { - stubMacLookup( - JSON.stringify({ - id: 42, - title: "main.ts", - bounds: { x: 10, y: 20, width: 800, height: 600 }, - owner: { - name: "Editor", - processId: 123, - path: "/Applications/Editor.app", - bundleId: "com.example.editor", - }, - }) + "\n", - ); - - const window = await activeWindow("darwin"); - - assert.deepEqual(window, { - platform: "macos", - id: 42, - title: "main.ts", - bounds: { x: 10, y: 20, width: 800, height: 600 }, - owner: { - name: "Editor", - processId: 123, - path: "/Applications/Editor.app", - bundleId: "com.example.editor", - }, - }); - const [file, args] = execFileMock.mock.calls[0]!; - assert.strictEqual(file, "/usr/bin/osascript"); - assert.deepEqual(args.slice(0, 3), ["-l", "JavaScript", "-e"]); - assert.lengthOf(loadWindowsForegroundApiMock.mock.calls, 0); -}); - -it("omits an empty macOS bundle identifier", async () => { - stubMacLookup( - JSON.stringify({ - id: 7, - title: "", - bounds: { x: 0, y: 0, width: 1, height: 1 }, - owner: { name: "cli", processId: 9, path: "", bundleId: "" }, - }), - ); - - const window = await activeWindow("darwin"); - - assert.deepEqual(window?.owner, { name: "cli", processId: 9, path: "" }); -}); - -it("resolves undefined when macOS has no frontmost window", async () => { - stubMacLookup("\n"); - - assert.isUndefined(await activeWindow("darwin")); -}); - -it("composes the Windows foreground window from Win32 calls", async () => { - const api = { - getForegroundWindow: vi.fn(() => 0x1_f123_4567n), - getWindowRect: vi.fn(() => ({ x: 10, y: 20, width: 800, height: 600 })), - getWindowThreadAndProcessId: vi.fn(() => ({ threadId: 5, processId: 123 })), - getProcessImagePath: vi.fn(() => "C:\\Program Files\\Editor\\editor.exe"), - getWindowText: vi.fn(() => "main.ts - Editor"), - }; - loadWindowsForegroundApiMock.mockResolvedValue(api); - - const window = await activeWindow("win32"); - - assert.deepEqual(window, { - platform: "windows", - id: 0x1_f123_4567, - title: "main.ts - Editor", - bounds: { x: 10, y: 20, width: 800, height: 600 }, - owner: { name: "editor.exe", processId: 123, path: "C:\\Program Files\\Editor\\editor.exe" }, - }); - assert.deepEqual(api.getWindowRect.mock.calls, [[0x1_f123_4567n]]); - assert.deepEqual(api.getProcessImagePath.mock.calls, [[123]]); - assert.lengthOf(execFileMock.mock.calls, 0); -}); - -it("resolves undefined when Windows reports no foreground window", async () => { - const api = { - getForegroundWindow: vi.fn(() => 0n), - getWindowRect: vi.fn(), - getWindowThreadAndProcessId: vi.fn(), - getProcessImagePath: vi.fn(), - getWindowText: vi.fn(), - }; - loadWindowsForegroundApiMock.mockResolvedValue(api); - - assert.isUndefined(await activeWindow("win32")); - assert.lengthOf(api.getWindowRect.mock.calls, 0); -}); - -it("resolves undefined on other platforms without querying the OS", async () => { - assert.isUndefined(await activeWindow("linux")); - assert.lengthOf(execFileMock.mock.calls, 0); - assert.lengthOf(loadWindowsForegroundApiMock.mock.calls, 0); -}); diff --git a/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts b/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts index ea6e176b8204..e2a3b913e648 100644 --- a/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts +++ b/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts @@ -2,10 +2,8 @@ import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as NodeOS from "node:os"; -import * as NodeChildProcess from "node:child_process"; -import * as NodeUtil from "node:util"; import { afterEach, beforeEach, expect, it as test, vi } from "vite-plus/test"; -import { CaptureShortcutConfig, niriCaptureConfigPath } from "./CaptureShortcutConfig.ts"; +import { CaptureShortcutConfig } from "./CaptureShortcutConfig.ts"; import { captureConfigBinding } from "./captureConfigEdit.ts"; // oxlint-disable-next-line t3code/no-global-process-runtime -- Test collection checks the host before starting these Linux-native filesystem tests. @@ -50,12 +48,6 @@ it("reads without writing, then applies only the approved diff with a backup", a expect(tools.validateNiri).toHaveBeenCalledOnce(); await expect(setup.apply(preview.id, "niri")).rejects.toThrow("expired"); }); -it("refuses apply without a preview or for the wrong desktop", async () => { - await expect(setup.apply("invented", "niri")).rejects.toThrow("expired"); - const preview = await setup.preview(target(), install); - await expect(setup.apply(preview.id, "hyprland")).rejects.toThrow("expired"); - expect(await NodeFSP.readFile(path, "utf8")).toBe(preview.before); -}); it("rechecks Hyprland conflicts before writing", async () => { const hypr = { ...target(), @@ -80,11 +72,6 @@ it("notices an optional include created while the diff is open", async () => { await expect(setup.apply(preview.id, "niri")).rejects.toThrow("changed since"); expect(await NodeFSP.readFile(path, "utf8")).toBe(preview.before); }); -it("invalidates an earlier proposal when another config is opened", async () => { - const first = await setup.preview(target(), install); - await setup.preview(target(), { ...install, shortcut: "Ctrl+Alt+Y" }); - await expect(setup.apply(first.id, "niri")).rejects.toThrow("expired"); -}); it("does not overwrite edits made after preview", async () => { const preview = await setup.preview(target(), install); await NodeFSP.appendFile(path, "// edited elsewhere\n"); @@ -243,52 +230,6 @@ it("detects conflicts in included Niri configs without changing either file", as await expect(setup.preview(target(), install)).rejects.toThrow("already used"); expect((await NodeFSP.readdir(directory)).sort()).toEqual(["config.kdl", "keys.kdl"]); }); -it("detects included config changes before applying", async () => { - await NodeFSP.appendFile(path, 'include "keys.kdl"\n'); - const included = NodePath.join(directory, "keys.kdl"); - await NodeFSP.writeFile(included, "binds { Ctrl+Alt+Q { quit; } }"); - const preview = await setup.preview(target(), install); - await NodeFSP.writeFile(included, "binds { Ctrl+Shift+2 { quit; } }"); - await expect(setup.apply(preview.id, "niri")).rejects.toThrow("changed since"); -}); -it("rejects a conflict from another Hyprland config or dynamic Lua bind", async () => { - const hypr = { - ...target(), - desktop: "hyprland" as const, - path: NodePath.join(directory, "bindings.lua"), - }; - await NodeFSP.writeFile(hypr.path, "-- user bindings\n"); - tools.hyprlandBindings.mockResolvedValue([ - { modmask: 5, key: "2", dispatcher: "__lua", arg: "92" }, - ]); - await expect(setup.preview(hypr, install)).rejects.toThrow("already used"); - expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe("-- user bindings\n"); -}); -it("reloads Hyprland after apply, but not after read", async () => { - const hypr = { - ...target(), - desktop: "hyprland" as const, - path: NodePath.join(directory, "hyprland.conf"), - }; - await NodeFSP.writeFile(hypr.path, "# preferences\n"); - const preview = await setup.preview(hypr, install); - expect(tools.reloadHyprland).not.toHaveBeenCalled(); - await setup.apply(preview.id, "hyprland"); - expect(tools.reloadHyprland).toHaveBeenCalledOnce(); -}); -it("reports reload failure separately from a successfully saved config", async () => { - const hypr = { - ...target(), - desktop: "hyprland" as const, - path: NodePath.join(directory, "hyprland.conf"), - }; - await NodeFSP.writeFile(hypr.path, "# preferences\n"); - const preview = await setup.preview(hypr, install); - tools.reloadHyprland.mockRejectedValue(new Error("Compositor stopped")); - const result = await setup.apply(preview.id, "hyprland"); - expect(result.warning).toContain("Config saved"); - expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe(preview.after); -}); it("does not edit Omarchy's shipped defaults", async () => { const defaults = NodePath.join(directory, "omarchy", "default", "hypr"); await NodeFSP.mkdir(defaults, { recursive: true }); @@ -298,46 +239,3 @@ it("does not edit Omarchy's shipped defaults", async () => { setup.preview({ ...target(), desktop: "hyprland", path: file }, install), ).rejects.toThrow("not system or Omarchy"); }); -it("honors NIRI_CONFIG and XDG_CONFIG_HOME", () => { - expect( - niriCaptureConfigPath("/home/test", { - NIRI_CONFIG: "/custom/test.kdl", - XDG_CONFIG_HOME: "/config", - }), - ).toBe("/custom/test.kdl"); - expect(niriCaptureConfigPath("/home/test", { XDG_CONFIG_HOME: "/config" })).toBe( - "/config/niri/config.kdl", - ); - expect(niriCaptureConfigPath("/home/test", {})).toBe("/home/test/.config/niri/config.kdl"); -}); -it("preserves a UTF-8 BOM through preview and apply", async () => { - await NodeFSP.writeFile(path, "\uFEFFbinds {}\r\n"); - const preview = await setup.preview(target(), install); - await setup.apply(preview.id, "niri"); - expect((await NodeFSP.readFile(path)).subarray(0, 3)).toEqual(Buffer.from([0xef, 0xbb, 0xbf])); -}); -it("the installed Niri parser accepts the generated edit and its relative includes", async (context) => { - try { - await NodeUtil.promisify(NodeChildProcess.execFile)("niri", ["--version"]); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - context.skip(); - return; - } - throw error; - } - await NodeFSP.appendFile(path, 'include "keys.kdl"\n'); - await NodeFSP.writeFile( - NodePath.join(directory, "keys.kdl"), - "binds {\n Ctrl+Alt+Q { quit; }\n}\n", - ); - const checked = new CaptureShortcutConfig(NodeOS.homedir(), { - ...tools, - validateNiri: async (file) => { - await NodeUtil.promisify(NodeChildProcess.execFile)("niri", ["validate", "--config", file]); - }, - }); - const preview = await checked.preview(target(), install); - await checked.apply(preview.id, "niri"); - expect(await NodeFSP.readFile(path, "utf8")).toBe(preview.after); -}); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index d2520ee03d2f..39a7dddf5de7 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -11,7 +11,6 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; @@ -476,12 +475,7 @@ const decodePendingMetadata = Schema.decodeUnknownEffect( Schema.fromJsonString(DesktopPendingSnapShot), ); -import { - SnapShotTransition, - snapShotAnimationDurationMs, - snapShotAnimationDisplayBounds, - snapShotAnimationOverlayBounds, -} from "./SnapShotTransition.ts"; +import { SnapShotTransition, snapShotAnimationDisplayBounds } from "./SnapShotTransition.ts"; const layerTest = ( platform: NodeJS.Platform, fileSystemOverrides: Parameters[0] = {}, @@ -760,116 +754,6 @@ function concurrentCaptureFixture(platform: NodeJS.Platform, animations: boolean }; } -it.effect("rejects desktop config changes outside Niri or Hyprland", () => - Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const previewError = yield* service - .previewConfig({ operation: "install", chooseFile: false }) - .pipe(Effect.flip); - const applyError = yield* service.applyConfig("unapproved-preview").pipe(Effect.flip); - assert.equal(previewError.action, "preview-config"); - assert.equal(applyError.action, "apply-config"); - for (const error of [previewError, applyError]) { - assert.equal(error.reason, "unsupported-session"); - assert.equal(error.message, "Config setup requires a Niri or Hyprland session."); - } - }), - ).pipe(Effect.provide(layerTest("win32"))), -); - -it.effect("reads and acknowledges queued captures through Effect services", () => { - const captureId = "12345678-1234-1234-1234-123456789abc"; - const captureDirectory = "/state/snap-shots"; - const metadataPath = captureDirectory + "/" + captureId + ".json"; - const imagePath = captureDirectory + "/" + captureId + ".png"; - const removed: Array = []; - const metadata = JSON.stringify({ - id: captureId, - name: "window.png", - mimeType: "image/png", - sizeBytes: 3, - source: { - kind: "snap-shot", - capturedAt: "2026-08-24T11:00:00.000Z", - appName: "Editor", - windowTitle: "main.ts", - }, - }); - const layer = layerTest("linux", { - readDirectory: () => Effect.succeed([captureId + ".json", "invalid.json"]), - readFileString: (filePath) => Effect.succeed(filePath === metadataPath ? metadata : "invalid"), - readFile: () => Effect.succeed(new Uint8Array([1, 2, 3])), - remove: (filePath) => - Effect.sync(() => { - removed.push(filePath); - }), - }); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const pending = yield* service.listPending; - assert.deepEqual( - pending.map((capture) => capture.id), - [captureId], - ); - - const capture = yield* service.read(captureId); - assert.strictEqual(capture.dataUrl, "data:image/png;base64,AQID"); - - yield* service.acknowledge(captureId); - assert.deepEqual(removed.sort(), [imagePath, metadataPath].sort()); - }), - ).pipe(Effect.provide(layer)); -}); - -it.effect("captures the active Windows window without enumerating desktop sources", () => { - const png = Buffer.from([1, 2, 3]); - const active = { - platform: "windows", - id: 42, - title: "Untitled - Paint", - owner: { name: "Paint.exe", processId: 123, path: "C:\\Windows\\System32\\mspaint.exe" }, - bounds: { x: 10, y: 20, width: 800, height: 600 }, - } as const; - activeWindowMock.mockReset().mockResolvedValue(active); - accessibilityByPidMock.mockReset().mockResolvedValue({ children: async () => [] }); - regionCaptureMock.mockReset().mockResolvedValue({ width: 800, height: 600, png }); - getSourcesMock.mockReset(); - getFileIconMock.mockReset().mockResolvedValue(fakeIcon("file")); - const writtenFiles: Array<[string, Uint8Array]> = []; - let metadata = ""; - const layer = layerTest("win32", { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFile: (path, bytes) => - Effect.sync(() => { - writtenFiles.push([path, bytes]); - }), - writeFileString: (_, text) => - Effect.sync(() => { - metadata = text; - }), - }); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(enabledSettings()); - yield* service.capture; - - assert.deepEqual(regionCaptureMock.mock.calls, [[active.bounds]]); - assert.lengthOf(getSourcesMock.mock.calls, 0); - assert.deepEqual(getFileIconMock.mock.calls, [[active.owner.path, { size: "normal" }]]); - assert.deepEqual(writtenFiles[0]?.[1], png); - const saved = yield* decodePendingMetadata(metadata); - assert.equal(saved.source.appName, "Paint"); - assert.match(saved.source.appIconDataUrl ?? "", /base64,file:/); - }), - ).pipe(Effect.provide(layer)); -}); - it.effect.each(["darwin", "win32"] as const)( "drops shortcut bursts during flight and accepts a fresh capture after landing on %s", (platform) => { @@ -987,174 +871,6 @@ it.effect.each([ }, ); -it.effect.each(["win32", "darwin", "linux"] as const)( - "captures the foreground window in place from the shortcut on %s", - (platform) => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - registerShortcutMock.mockReset().mockReturnValue(true); - mediaAccessStatusMock.mockReturnValue("granted"); - animationSettingsMock.mockReturnValue({ - prefersReducedMotion: false, - shouldRenderRichAnimation: true, - }); - const bounds = { x: 10, y: 20, width: 800, height: 600 }; - const t3 = { - id: 42, - title: "T3 Code", - appIdentifier: "com.t3tools.T3Code.desktop", - owner: { name: "T3 Code", processId: 123 }, - bounds, - png: Buffer.from([1, 2, 3]), - }; - focusedWindowMock.mockReturnValue({ - getBounds: () => bounds, - getTitle: () => t3.title, - isDestroyed: () => false, - isMinimized: () => false, - isVisible: () => true, - show: vi.fn(), - restore: vi.fn(), - }); - const images: Uint8Array[] = []; - activeWindowMock.mockReset().mockResolvedValue({ - ...t3, - platform: platform === "darwin" ? "macos" : "windows", - }); - regionCaptureMock.mockReset().mockResolvedValue({ - width: bounds.width, - height: bounds.height, - png: t3.png, - }); - macCaptureMock.mockReset().mockImplementation(async () => { - images.push(t3.png); - return { source: { name: t3.title }, png: t3.png }; - }); - const activate = vi.fn<(title: string) => Promise>().mockResolvedValue(undefined); - linuxCaptureMock.mockResolvedValueOnce({ - png: t3.png, - window: { - title: t3.title, - appName: t3.owner.name, - appIdentifier: t3.appIdentifier, - processId: t3.owner.processId, - bounds, - }, - feedback: { - animationStarted: true, - activate, - animateTo: async () => undefined, - complete: async () => undefined, - close: () => undefined, - }, - }); - const readAccessibility = accessibilityProcessReadMock.getMockImplementation()!; - accessibilityProcessReadMock.mockImplementation(({ active }) => ({ - started: Promise.resolve(), - result: Promise.resolve({ accessibleText: `Window from process ${active.owner.processId}` }), - })); - let metadata = ""; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure( - enabledSettings({ - snapShotFlash: false, - snapShotShortcut: { - key: "2", - ctrlKey: true, - shiftKey: true, - altKey: false, - metaKey: false, - modKey: false, - }, - }), - ); - const trigger = - platform === "linux" - ? portalShortcutInstances.at(-1)!.onCapture - : registerShortcutMock.mock.calls.at(-1)![1]; - yield* Effect.promise(trigger); - - const saved = yield* decodePendingMetadata(metadata); - assert.equal(saved.source.windowTitle, t3.title); - assert.equal(saved.source.appName, t3.owner.name); - assert.equal(saved.source.accessibleText, `Window from process ${t3.owner.processId}`); - assert.deepEqual(images, [t3.png]); - assert.equal(prepareCaptureRevealMock.mock.calls.length, platform === "win32" ? 1 : 0); - if (platform === "linux") { - assert.equal(saved.source.appIdentifier, t3.appIdentifier); - assert.deepEqual(activate.mock.calls, [[t3.title]]); - } - }), - ).pipe( - Effect.provide( - layerTest(platform, { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFile: (_, bytes) => - Effect.sync(() => { - images.push(bytes); - }), - writeFileString: (_, text) => - Effect.sync(() => { - metadata = text; - }), - }), - ), - Effect.ensuring( - Effect.sync(() => { - focusedWindowMock.mockReset(); - linuxCaptureMock.mockReset().mockResolvedValue(undefined); - accessibilityProcessReadMock.mockReset().mockImplementation(readAccessibility); - mediaAccessStatusMock.mockReturnValue("not-determined"); - animationSettingsMock.mockReturnValue({ - prefersReducedMotion: true, - shouldRenderRichAnimation: false, - }); - vi.unstubAllEnvs(); - }), - ), - ); - }, -); - -it.effect.each(["win32", "darwin", "linux"] as const)( - "ignores shortcut repeats for 200 ms without delaying the first capture on %s", - (platform) => { - const fixture = concurrentCaptureFixture(platform, false); - fixture.releaseAll(); - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(fixture.settings); - yield* service.setShortcutSuppressed(true); - yield* Effect.promise(fixture.trigger); - assert.equal(fixture.state.snapshots, 0); - yield* service.setShortcutSuppressed(false); - yield* Effect.promise(fixture.trigger); - assert.equal(fixture.state.snapshots, 1); - assert.lengthOf(fixture.readyIds, 1); - - yield* Effect.promise(fixture.trigger); - yield* TestClock.adjust("199 millis"); - yield* Effect.promise(fixture.trigger); - assert.equal(fixture.state.snapshots, 1); - assert.lengthOf(fixture.readyIds, 1); - - yield* TestClock.adjust("1 millis"); - yield* Effect.promise(fixture.trigger); - assert.equal(fixture.state.snapshots, 2); - assert.lengthOf(fixture.readyIds, 2); - - yield* service.capture; - assert.equal(fixture.state.snapshots, 3); - assert.lengthOf(fixture.readyIds, 3); - }), - ).pipe(Effect.provide(fixture.layer), Effect.ensuring(Effect.sync(fixture.reset))); - }, -); - it.effect.each([ { platform: "win32", animations: true }, { platform: "darwin", animations: false }, @@ -1268,41 +984,6 @@ it.effect.each(["succeeds", "fails"] as const)( }, ); -it.effect("keeps newer native feedback when older accessibility persistence fails", () => { - const fixture = concurrentCaptureFixture("linux", true); - fixture.state.failFirstPersistence = true; - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(fixture.settings); - fixture.first.pixels.resolve(); - const first = yield* Effect.promise(fixture.trigger).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* Effect.promise(() => fixture.first.handoff.promise); - yield* TestClock.adjust("200 millis"); - fixture.second.pixels.resolve(); - const second = yield* Effect.promise(fixture.trigger).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* Effect.promise(() => fixture.second.handoff.promise); - fixture.first.context.resolve({ accessibleText: "Discord accessibility" }); - yield* Fiber.join(first); - - assert.lengthOf(fixture.readyIds, 0); - assert.lengthOf(fixture.second.feedback.close.mock.calls, 0); - assert.lengthOf(fixture.second.feedback.complete.mock.calls, 0); - fixture.second.context.resolve({ accessibleText: "Explorer accessibility" }); - yield* Fiber.join(second); - assert.lengthOf(fixture.readyIds, 1); - const newer = yield* service.read(fixture.readyIds[0]!); - assert.equal(newer.source.windowTitle, fixture.second.title); - yield* service.acknowledge(fixture.readyIds[0]!); - assert.lengthOf(fixture.second.feedback.complete.mock.calls, 1); - }).pipe(Effect.ensuring(Effect.sync(fixture.releaseAll))), - ).pipe(Effect.provide(fixture.layer), Effect.ensuring(Effect.sync(fixture.reset))); -}); - it.effect("matches Windows accessibility windows on a scaled display", () => { const png = Buffer.from([1, 2, 3]); const active = { @@ -1368,94 +1049,6 @@ it.effect("matches Windows accessibility windows on a scaled display", () => { ); }); -it.effect("skips accessibility capture when the setting is disabled", () => { - const png = Buffer.from([1, 2, 3]); - const active = { - platform: "windows", - id: 42, - title: "Editor", - owner: { name: "Editor", processId: 123 }, - bounds: { x: 10, y: 20, width: 800, height: 600 }, - } as const; - activeWindowMock.mockReset().mockResolvedValue(active); - regionCaptureMock.mockReset().mockResolvedValue({ width: 800, height: 600, png }); - accessibilityProcessWarmMock.mockClear(); - accessibilityProcessReadMock.mockClear(); - accessibilityByPidMock.mockClear(); - const layer = layerTest("win32", { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFile: () => Effect.void, - writeFileString: () => Effect.void, - }); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(enabledSettings({ snapShotIncludeAccessibility: false })); - yield* service.capture; - - assert.lengthOf(accessibilityProcessWarmMock.mock.calls, 0); - assert.lengthOf(accessibilityProcessReadMock.mock.calls, 0); - assert.lengthOf(accessibilityByPidMock.mock.calls, 0); - }), - ).pipe(Effect.provide(layer)); -}); - -it.effect("keeps an accessibility helper warm only while capture data is enabled", () => { - accessibilityProcessWarmMock.mockClear(); - accessibilityProcessCoolMock.mockClear(); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure({ - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotIncludeAccessibility: true, - }); - - assert.lengthOf(accessibilityProcessWarmMock.mock.calls, 1); - assert.lengthOf(accessibilityProcessCoolMock.mock.calls, 0); - - yield* service.configure({ - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: false, - snapShotIncludeAccessibility: true, - }); - - assert.lengthOf(accessibilityProcessCoolMock.mock.calls, 1); - }), - ).pipe(Effect.provide(layerTest("win32"))); -}); - -it.effect("rejects X11 capture without registering shortcuts or loading capture backends", () => { - vi.stubEnv("XDG_SESSION_TYPE", "x11"); - vi.stubEnv("WAYLAND_DISPLAY", ""); - linuxCaptureMock.mockClear(); - activeWindowMock.mockClear(); - registerShortcutMock.mockClear(); - shortcutProcesses.length = 0; - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure({ ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true }); - const state = yield* service.state; - assert.equal(state.mode, "unavailable"); - assert.include(state.message, "Wayland"); - const result = yield* Effect.flip(service.capture); - assert.equal(result.operation, "unsupported"); - assert.lengthOf(registerShortcutMock.mock.calls, 0); - assert.lengthOf(shortcutProcesses, 0); - assert.lengthOf(linuxCaptureMock.mock.calls, 0); - assert.lengthOf(activeWindowMock.mock.calls, 0); - }), - ).pipe( - Effect.provide(layerTest("linux")), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); -}); - it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( "persists %s text without inventing AT-SPI screen coordinates", (backend) => { @@ -1553,613 +1146,28 @@ it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( }, ); -it.effect("persists a window capture from a portal activation without a setup test", () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - linuxCaptureMock.mockClear().mockResolvedValueOnce({ - png: Buffer.from([1, 2, 3]), - window: { - title: "Shortcut capture", - appName: "Text Editor", - appIdentifier: "org.kde.kwrite", - processId: 123, - bounds: { x: 0, y: 0, width: 700, height: 520 }, - }, - }); - let metadata = ""; - const writes: Uint8Array[] = []; - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure({ - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotIncludeAccessibility: false, - snapShotShortcut: { - key: "2", - ctrlKey: true, - shiftKey: true, - altKey: false, - metaKey: false, - modKey: false, - }, - }); - yield* Effect.promise(portalShortcutInstances[0]!.onCapture); - assert.lengthOf(linuxCaptureMock.mock.calls, 1); - assert.deepEqual(writes, [Buffer.from([1, 2, 3])]); - const saved = yield* decodePendingMetadata(metadata); - assert.equal(saved.source.windowTitle, "Shortcut capture"); - assert.equal(saved.source.appIdentifier, "org.kde.kwrite"); - const state = yield* service.state; - assert.isTrue(state.shortcutVerified); - assert.isNull(state.message); - const portal = portalShortcutInstances[0]!; - portal.state = { - shortcutRegistered: false, - shortcutPending: false, - shortcutMessage: "Permission revoked", - }; - portal.onStateChanged(); - const revoked = yield* service.state; - assert.isFalse(revoked.shortcutVerified); - assert.isFalse(revoked.shortcutRegistered); - }), - ).pipe( - Effect.provide( - layerTest("linux", { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFile: (_, bytes) => - Effect.sync(() => { - writes.push(bytes); - }), - writeFileString: (_, text) => - Effect.sync(() => { - metadata = text; - }), - }), +it("covers straddling captures and intervening monitors without spanning unrelated displays", () => { + const displays = [ + { bounds: { x: -1_920, y: 0, width: 1_920, height: 1_080 } }, + { bounds: { x: 0, y: 0, width: 1_440, height: 900 } }, + { bounds: { x: 1_440, y: 0, width: 1_280, height: 720 } }, + { bounds: { x: 0, y: -1_080, width: 1_920, height: 1_080 } }, + ]; + const source = { x: -200, y: 100, width: 800, height: 600 }; + assert.deepEqual( + snapShotAnimationDisplayBounds(displays, source, source), + displays.slice(0, 2).map((display) => display.bounds), + ); + assert.deepEqual( + snapShotAnimationDisplayBounds( + displays, + { x: -1_800, y: 100, width: 800, height: 600 }, + { x: 1_600, y: 200, width: 200, height: 112 }, ), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), + displays.slice(0, 3).map((display) => display.bounds), ); }); -it.effect("does not fall back to the picker when an automatic Wayland capture fails", () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - linuxCaptureMock.mockRejectedValueOnce(new Error("Capture denied")); - getSourcesMock.mockClear(); - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(enabledSettings()); - const failure = yield* Effect.flip(service.capture); - assert.equal(failure.operation, "capture"); - assert.lengthOf(getSourcesMock.mock.calls, 0); - }), - ).pipe( - Effect.provide( - layerTest("linux", { - makeDirectory: () => Effect.void, - remove: () => Effect.void, - }), - ), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); -}); - -it.effect( - "logs a GNOME activation failure and completes the shell flight before acknowledging the image", - () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - const order: string[] = []; - const activationFailure = new Error("Activation failed"); - const logs: Array = []; - const logger = Logger.make(({ message }) => logs.push(message)); - const feedback = { - animationStarted: true, - activate: async () => { - order.push("activate"); - throw activationFailure; - }, - animateTo: async () => { - order.push("land"); - }, - complete: async () => { - order.push("complete"); - }, - close: () => { - order.push("close"); - }, - }; - linuxCaptureMock.mockImplementationOnce(async () => { - order.push("snapshot"); - return { png: Buffer.from([1, 2, 3]), feedback }; - }); - focusedWindowMock.mockReturnValue(undefined); - const destination = { - getBounds: () => ({ x: 0, y: 0, width: 1000, height: 800 }), - getTitle: () => "T3 Code", - isDestroyed: () => false, - isVisible: () => true, - isMinimized: () => false, - }; - allWindowsMock.mockReturnValue([destination]); - let saved = ""; - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(enabledSettings()); - yield* service.capture; - assert.deepEqual(order, ["snapshot", "activate"]); - const warning = logs.find( - (message) => - Array.isArray(message) && - message[0] === "The compositor could not activate T3 Code after the snapshot", - ); - assert.strictEqual(Array.isArray(warning) ? warning[1] : undefined, activationFailure); - const pending = yield* decodePendingMetadata(saved); - yield* service.setAnimationDestination(pending.id, { - frame: { x: 0, y: 0, width: 10, height: 10 }, - relativeFrame: { x: 0.1, y: 0.8, width: 0.2, height: 0.1 }, - backgroundColor: "white", - borderColor: "black", - borderWidth: 1, - cornerRadius: 8, - scaleFactor: 1, - }); - yield* service.acknowledge(pending.id); - assert.deepEqual(order, ["snapshot", "activate", "land", "complete", "delete", "delete"]); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - layerTest("linux", { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFile: () => Effect.void, - writeFileString: (_, value) => - Effect.sync(() => { - saved = value; - }), - remove: () => - Effect.sync(() => { - order.push("delete"); - }), - }), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - Effect.ensuring( - Effect.sync(() => { - allWindowsMock.mockReturnValue([]); - vi.unstubAllEnvs(); - }), - ), - ); - }, -); - -it.effect("does not read unverified accessibility context for a Wayland portal capture", () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - const png = Buffer.from([1, 2, 3]); - accessibilityForegroundMock.mockReset(); - getSourcesMock.mockReset().mockResolvedValue([ - { - id: "window:42:0", - name: "Untitled", - thumbnail: { isEmpty: () => false, toPNG: () => png }, - }, - ]); - const layer = layerTest("linux", { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFile: () => Effect.void, - writeFileString: () => Effect.void, - }); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(enabledSettings()); - yield* service.capture; - - assert.lengthOf(accessibilityForegroundMock.mock.calls, 0); - }), - ).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); -}); - -it.effect("uses display-local macOS capture surfaces across the source and main displays", () => { - const png = Buffer.from([1, 2, 3]); - const active = { - platform: "macos", - id: 42, - title: "Terminal", - owner: { name: "Terminal", processId: 123 }, - bounds: { x: 10, y: 20, width: 800, height: 600 }, - } as const; - activeWindowMock.mockReset().mockResolvedValue(active); - accessibilityByPidMock.mockReset().mockResolvedValue({ children: async () => [] }); - macCaptureMock.mockReset().mockResolvedValue({ - source: { name: "Terminal" }, - png, - }); - animationSettingsMock.mockReturnValueOnce({ - prefersReducedMotion: false, - shouldRenderRichAnimation: true, - }); - focusedWindowMock.mockReturnValue({ - getBounds: () => ({ x: -1_600, y: 100, width: 1_200, height: 800 }), - isDestroyed: () => false, - }); - flashWindows.length = 0; - const layer = layerTest("darwin", { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFileString: () => Effect.void, - }); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(enabledSettings()); - yield* service.capture; - - const transitionWindows = flashWindows.filter((window) => window.kind === "browser"); - assert.deepEqual( - transitionWindows.map((window) => window.bounds), - [ - { x: 0, y: -200, width: 1_440, height: 900 }, - { x: -1_920, y: 0, width: 1_920, height: 1_080 }, - ], - ); - for (const transitionWindow of transitionWindows) { - assert.deepEqual(transitionWindow.alwaysOnTopCalls, [[true, "pop-up-menu"]]); - } - }), - ).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => focusedWindowMock.mockReset()))); -}); - -it.effect.each(["ready", "failed"] as const)( - "shows the Windows transition only once its snapshot decode is %s", - (outcome) => { - const png = Buffer.from([1, 2, 3]); - activeWindowMock.mockReset().mockResolvedValue({ - platform: "windows", - id: 42, - title: "Editor", - owner: { name: "Editor", processId: 123 }, - bounds: { x: 10, y: 20, width: 800, height: 600 }, - }); - regionCaptureMock.mockReset().mockResolvedValue({ width: 800, height: 600, png }); - animationSettingsMock.mockReturnValueOnce({ - prefersReducedMotion: false, - shouldRenderRichAnimation: true, - }); - const decoding = Promise.withResolvers(); - const decoded = Promise.withResolvers(); - transitionSnapshotMock.mockImplementation(() => { - decoding.resolve(); - return decoded.promise; - }); - focusedWindowMock.mockReturnValue({ - getBounds: () => ({ x: 100, y: 50, width: 1_200, height: 800 }), - isDestroyed: () => false, - }); - transitionShowMock.mockClear(); - flashWindows.length = 0; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure( - enabledSettings({ snapShotIncludeAccessibility: false, snapShotFlash: false }), - ); - const capture = yield* service.capture.pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => decoding.promise); - assert.lengthOf(transitionShowMock.mock.calls, 0); - - if (outcome === "ready") decoded.resolve(); - else decoded.reject(new Error("Snapshot decode failed")); - yield* Fiber.join(capture); - - if (outcome === "ready") { - assert.isNotEmpty(transitionShowMock.mock.calls); - } else { - assert.lengthOf(transitionShowMock.mock.calls, 0); - assert.isTrue(flashWindows.every((window) => window.destroyed)); - } - }), - ).pipe( - Effect.provide( - layerTest("win32", { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFile: () => Effect.void, - writeFileString: () => Effect.void, - }), - ), - Effect.ensuring( - Effect.sync(() => { - decoded.resolve(); - focusedWindowMock.mockReset(); - }), - ), - ); - }, -); - -it.effect("uses the unfocused main window for a macOS cross-display transition", () => { - const png = Buffer.from([1, 2, 3]); - const active = { - platform: "macos", - id: 42, - title: "Terminal", - owner: { name: "Terminal", processId: 123 }, - bounds: { x: 10, y: 20, width: 800, height: 600 }, - } as const; - activeWindowMock.mockReset().mockResolvedValue(active); - accessibilityByPidMock.mockReset().mockResolvedValue({ children: async () => [] }); - macCaptureMock.mockReset().mockResolvedValue({ source: { name: "Terminal" }, png }); - animationSettingsMock.mockReturnValueOnce({ - prefersReducedMotion: false, - shouldRenderRichAnimation: true, - }); - focusedWindowMock.mockReturnValue(undefined); - allWindowsMock.mockReturnValue([ - { - getBounds: () => ({ x: -1_600, y: 100, width: 1_200, height: 800 }), - isDestroyed: () => false, - }, - ]); - flashWindows.length = 0; - const layer = layerTest("darwin", { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFileString: () => Effect.void, - }); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(enabledSettings()); - yield* service.capture; - - assert.deepEqual( - flashWindows.filter((window) => window.kind === "browser").map((window) => window.bounds), - [ - { x: 0, y: -200, width: 1_440, height: 900 }, - { x: -1_920, y: 0, width: 1_920, height: 1_080 }, - ], - ); - }), - ).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - focusedWindowMock.mockReset(); - allWindowsMock.mockReset(); - }), - ), - ); -}); - -function fakeIcon(label: string, empty = false): Electron.NativeImage { - return { - isEmpty: () => empty, - resize: ({ width, height, quality }) => ({ - toDataURL: (options) => - "data:image/png;base64," + - label + - ":" + - width + - "x" + - height + - ":" + - quality + - "@" + - options?.scaleFactor, - }), - } as Electron.NativeImage; -} - -it.each([ - ["OS app", fakeIcon("file"), fakeIcon("captured"), "file"], - ["captured app", fakeIcon("file", true), fakeIcon("captured"), "captured"], -])("exports the %s icon at high density", (_source, fileIcon, capturedIcon, expectedLabel) => { - const dataUrl = DesktopSnapShot.snapShotIconDataUrl(fileIcon, capturedIcon); - - assert.strictEqual(dataUrl, "data:image/png;base64," + expectedLabel + ":64x64:best@2"); -}); - -const activeEditor = { - owner: { path: "/Applications/Editor.app" }, -} as Parameters[1]; - -it.each([ - [ - "a failed thumbnail on darwin", - "darwin" as const, - () => thumbnailFromPathMock.mockRejectedValue(new Error("no thumbnail")), - ], - ["other platforms", "win32" as const, () => {}], -])( - "requests the file icon at a size supported on macOS after %s", - async (_case, platform, arrange) => { - getFileIconMock.mockReset(); - thumbnailFromPathMock.mockReset(); - arrange(); - getFileIconMock.mockResolvedValue(fakeIcon("file")); - - const dataUrl = await DesktopSnapShot.iconDataUrl( - { appIcon: fakeIcon("captured") }, - activeEditor, - platform, - ); - - assert.deepEqual(getFileIconMock.mock.calls, [ - ["/Applications/Editor.app", { size: "normal" }], - ]); - assert.strictEqual(dataUrl, "data:image/png;base64,file:64x64:best@2"); - }, -); - -it("uses the primary display for portal flash feedback", () => { - assert.deepEqual(DesktopSnapShot.snapShotFlashBounds(undefined, "linux"), { - x: 0, - y: 0, - width: 1_440, - height: 900, - }); -}); - -it("uses the operating system animation policy", () => { - assert.isTrue( - DesktopSnapShot.shouldAnimateSnapShot({ - prefersReducedMotion: false, - shouldRenderRichAnimation: true, - }), - ); - assert.isFalse( - DesktopSnapShot.shouldAnimateSnapShot({ - prefersReducedMotion: true, - shouldRenderRichAnimation: true, - }), - ); -}); - -it("scales transition timing with travel distance", () => { - const source = { x: 0, y: 0, width: 200, height: 100 }; - assert.strictEqual(snapShotAnimationDurationMs(source, source), 280); - const near = snapShotAnimationDurationMs(source, { - x: 100, - y: 0, - width: 200, - height: 100, - }); - const far = snapShotAnimationDurationMs(source, { - x: 1_000, - y: 0, - width: 200, - height: 100, - }); - - assert.isAtLeast(near, 280); - assert.isBelow(far, 570); - assert.isAbove(far, near); -}); - -it("bounds the transition surface to its displays", () => { - assert.deepEqual( - snapShotAnimationOverlayBounds([ - { bounds: { x: -1_920, y: 0, width: 1_920, height: 1_080 } }, - { bounds: { x: 0, y: -200, width: 1_440, height: 900 } }, - ]), - { x: -1_920, y: -200, width: 3_360, height: 1_280 }, - ); -}); - -it("covers straddling captures and intervening monitors without spanning unrelated displays", () => { - const displays = [ - { bounds: { x: -1_920, y: 0, width: 1_920, height: 1_080 } }, - { bounds: { x: 0, y: 0, width: 1_440, height: 900 } }, - { bounds: { x: 1_440, y: 0, width: 1_280, height: 720 } }, - { bounds: { x: 0, y: -1_080, width: 1_920, height: 1_080 } }, - ]; - const source = { x: -200, y: 100, width: 800, height: 600 }; - assert.deepEqual( - snapShotAnimationDisplayBounds(displays, source, source), - displays.slice(0, 2).map((display) => display.bounds), - ); - assert.deepEqual( - snapShotAnimationDisplayBounds( - displays, - { x: -1_800, y: 100, width: 800, height: 600 }, - { x: 1_600, y: 200, width: 200, height: 112 }, - ), - displays.slice(0, 3).map((display) => display.bounds), - ); -}); - -it("keeps cross-display handoff surfaces local to each display", async () => { - flashWindows.length = 0; - const transition = new SnapShotTransition({ - boundOverlayToCaptureDisplays: true, - }); - - try { - await transition.begin( - "capture-1", - { x: -1_800, y: 50, width: 900, height: 600 }, - "data:image/png;base64,", - false, - { x: 100, y: 50, width: 1_000, height: 700 }, - ); - transition.animateTo("capture-1", { - frame: { x: 600, y: 400, width: 208, height: 112 }, - backgroundColor: "#fff", - borderColor: "#ccc", - borderWidth: 1, - cornerRadius: 8, - scaleFactor: 1, - }); - await transition.waitForLanding("capture-1"); - - assert.deepEqual( - flashWindows.map((window) => window.bounds), - [ - { x: -1_920, y: 0, width: 1_920, height: 1_080 }, - { x: 0, y: -200, width: 1_440, height: 900 }, - ], - ); - for (const window of flashWindows) { - assert.strictEqual(window.resizeCount, 0); - } - } finally { - transition.dispose(); - } -}); - -it("adds a newly selected destination display without resizing existing surfaces", async () => { - flashWindows.length = 0; - const transition = new SnapShotTransition({ boundOverlayToCaptureDisplays: true }); - const snapshot = "data:image/png;base64,captured-window"; - - try { - await transition.begin( - "capture-1", - { x: -1_800, y: 50, width: 900, height: 600 }, - snapshot, - false, - { x: -1_600, y: 100, width: 1_000, height: 700 }, - ); - assert.lengthOf(flashWindows, 1); - const initialWindow = flashWindows[0]; - - transition.animateTo("capture-1", { - frame: { x: 600, y: 400, width: 208, height: 112 }, - backgroundColor: "#fff", - borderColor: "#ccc", - borderWidth: 1, - cornerRadius: 8, - scaleFactor: 1, - }); - await transition.waitForLanding("capture-1"); - - assert.strictEqual(flashWindows[0], initialWindow); - assert.deepEqual( - flashWindows.map((window) => window.bounds), - [ - { x: -1_920, y: 0, width: 1_920, height: 1_080 }, - { x: 0, y: -200, width: 1_440, height: 900 }, - ], - ); - for (const window of flashWindows) { - assert.strictEqual(window.resizeCount, 0); - assert.strictEqual(window.showCount, 1); - } - } finally { - transition.dispose(); - } -}); - it("cancels a late display while its snapshot is still decoding", async () => { flashWindows.length = 0; const transition = new SnapShotTransition({ boundOverlayToCaptureDisplays: true }); @@ -2203,52 +1211,6 @@ it("cancels a late display while its snapshot is still decoding", async () => { } }); -it("waits for every initial compositor frame before completing capture feedback", async () => { - flashWindows.length = 0; - const transition = new SnapShotTransition({ - boundOverlayToCaptureDisplays: true, - waitForCompositorFrame: true, - }); - const frames = [Promise.withResolvers(), Promise.withResolvers()]; - const readingFrames = Promise.withResolvers(); - let frameIndex = 0; - let ready = false; - transitionCapturePageMock.mockImplementation(() => { - const frame = frames[frameIndex++]!; - if (frameIndex === frames.length) readingFrames.resolve(); - return frame.promise; - }); - - try { - const begin = transition - .begin( - "capture-1", - { x: -1_800, y: 50, width: 900, height: 600 }, - "data:image/png;base64,", - true, - { x: 100, y: 50, width: 1_000, height: 700 }, - ) - .then(() => { - ready = true; - }); - await readingFrames.promise; - frames[0]!.resolve(); - await frames[0]!.promise; - - assert.isFalse(ready); - for (const window of flashWindows) { - assert.strictEqual(window.showCount, 1); - } - frames[1]!.resolve(); - await begin; - - assert.isTrue(ready); - } finally { - for (const frame of frames) frame.resolve(); - transition.dispose(); - } -}); - it("does not flash a dismissed overlay after its initial compositor receipt arrives", async () => { flashWindows.length = 0; const transition = new SnapShotTransition({ waitForCompositorFrame: true }); @@ -2282,56 +1244,6 @@ it("does not flash a dismissed overlay after its initial compositor receipt arri } }); -it("waits for every display's prepared compositor frame before starting any flight", async () => { - flashWindows.length = 0; - const transition = new SnapShotTransition({ - boundOverlayToCaptureDisplays: true, - waitForCompositorFrame: true, - }); - const frames = [Promise.withResolvers(), Promise.withResolvers()]; - const readingFrames = Promise.withResolvers(); - let frameIndex = 0; - try { - await transition.begin( - "capture-1", - { x: -1_800, y: 50, width: 900, height: 600 }, - "data:image/png;base64,", - false, - { x: 100, y: 50, width: 1_000, height: 700 }, - ); - transitionCapturePageMock.mockImplementation(() => { - const frame = frames[frameIndex++]!; - if (frameIndex === frames.length) readingFrames.resolve(); - return frame.promise; - }); - transition.animateTo("capture-1", { - frame: { x: 600, y: 400, width: 208, height: 112 }, - backgroundColor: "#fff", - borderColor: "#ccc", - borderWidth: 1, - cornerRadius: 8, - scaleFactor: 1, - }); - const landing = transition.waitForLanding("capture-1"); - await readingFrames.promise; - frames[0]!.resolve(); - await new Promise((resolve) => setImmediate(resolve)); - - for (const window of flashWindows) { - assert.deepEqual(window.capturedRegions, [ - { x: 0, y: 0, width: 1, height: 1 }, - { x: 0, y: 0, width: 1, height: 1 }, - ]); - } - - frames[1]!.resolve(); - await landing; - } finally { - for (const frame of frames) frame.resolve(); - transition.dispose(); - } -}); - it("does not start a dismissed transition after its compositor receipt arrives", async () => { flashWindows.length = 0; const transition = new SnapShotTransition({ waitForCompositorFrame: true }); @@ -2348,124 +1260,25 @@ it("does not start a dismissed transition after its compositor receipt arrives", readingFrame.resolve(); return frame.promise; }); - transition.animateTo("capture-1", { - frame: { x: 600, y: 400, width: 208, height: 112 }, - backgroundColor: "#fff", - borderColor: "#ccc", - borderWidth: 1, - cornerRadius: 8, - scaleFactor: 1, - }); - const landing = transition.waitForLanding("capture-1"); - await readingFrame.promise; - assert.isTrue(transition.isAnimating); - transition.dismiss("capture-1"); - assert.isFalse(transition.isAnimating); - frame.resolve(); - await landing; - - assert.isTrue(flashWindows[0]?.destroyed); - } finally { - frame.resolve(); - transition.dispose(); - } -}); - -it("keeps flying on the destination display when the capture display fails", async () => { - flashWindows.length = 0; - const transition = new SnapShotTransition({ - boundOverlayToCaptureDisplays: true, - }); - - try { - await transition.begin( - "capture-1", - { x: -1_800, y: 50, width: 900, height: 600 }, - "data:image/png;base64,", - false, - { x: 100, y: 50, width: 1_000, height: 700 }, - ); - transitionScriptState.rejectFlight = true; - transitionScriptState.heldFlights = []; - transition.animateTo("capture-1", { - frame: { x: 600, y: 400, width: 208, height: 112 }, - backgroundColor: "#fff", - borderColor: "#ccc", - borderWidth: 1, - cornerRadius: 8, - scaleFactor: 1, - }); - - let landed = false; - const landing = transition.waitForLanding("capture-1").then(() => { - landed = true; - }); - await new Promise((resolve) => setImmediate(resolve)); - - assert.lengthOf(transitionScriptState.heldFlights, 1); - assert.isFalse(landed); - - for (const release of transitionScriptState.heldFlights) release(); - await landing; - } finally { - transitionScriptState.rejectFlight = false; - transitionScriptState.heldFlights = null; - transition.dispose(); - } -}); - -it("keeps same-display motion inside one stable surface", async () => { - flashWindows.length = 0; - const transition = new SnapShotTransition({ - boundOverlayToCaptureDisplays: true, - }); - - try { - await transition.begin( - "capture-1", - { x: 100, y: 50, width: 900, height: 600 }, - "data:image/png;base64,", - false, - { x: 200, y: 100, width: 1_000, height: 700 }, - ); - transition.animateTo("capture-1", { - frame: { x: 600, y: 400, width: 208, height: 112 }, - backgroundColor: "#fff", - borderColor: "#ccc", - borderWidth: 1, - cornerRadius: 8, - scaleFactor: 1, - }); - await transition.waitForLanding("capture-1"); - - assert.deepEqual(flashWindows[0]?.bounds, { - x: 0, - y: -200, - width: 1_440, - height: 900, - }); - assert.strictEqual(flashWindows[0]?.resizeCount, 0); - } finally { - transition.dispose(); - } -}); - -it("keeps the Windows transition above the revealed main window", async () => { - flashWindows.length = 0; - const transition = new SnapShotTransition({ - alwaysOnTopLevel: "pop-up-menu", - }); - - try { - await transition.begin( - "capture-1", - { x: 100, y: 50, width: 900, height: 600 }, - "data:image/png;base64,", - false, - ); + transition.animateTo("capture-1", { + frame: { x: 600, y: 400, width: 208, height: 112 }, + backgroundColor: "#fff", + borderColor: "#ccc", + borderWidth: 1, + cornerRadius: 8, + scaleFactor: 1, + }); + const landing = transition.waitForLanding("capture-1"); + await readingFrame.promise; + assert.isTrue(transition.isAnimating); + transition.dismiss("capture-1"); + assert.isFalse(transition.isAnimating); + frame.resolve(); + await landing; - assert.deepEqual(flashWindows[0]?.alwaysOnTopCalls, [[true, "pop-up-menu"]]); + assert.isTrue(flashWindows[0]?.destroyed); } finally { + frame.resolve(); transition.dispose(); } }); @@ -2502,15 +1315,6 @@ it("does not let a failed transition fail landing or capture completion", async } }); -it("bounds source thumbnails for large windows", () => { - assert.deepEqual( - DesktopSnapShot.snapShotThumbnailSize({ - bounds: { x: 0, y: 0, width: 6_000, height: 4_000 }, - } as Parameters[0]), - { width: 2_560, height: 1_600 }, - ); -}); - it.each(["client", "frame"] as const)( "maps KDE %s accessibility coordinates into the decorated screenshot", async (rootArea) => { @@ -2651,8 +1455,6 @@ it.each(["darwin", "win32"] as const)( it.each([ ["darwin", "", "value", 650], - ["win32", "", "value", 650], - ["linux", "x11", "value", 650], ["linux", "wayland", "value", 650], ["darwin", "", "name", 100], ] as const)( @@ -2725,6 +1527,69 @@ it.each([ } }); +it.each([ + ["com.google.Chrome", "Google Chrome – Profile"], + ["org.chromium.Chromium", "Chromium"], +])("reads macOS %s page context with an accessibility title suffix", async (bundleId, suffix) => { + const title = "Issues · pingdotgg/t3code"; + const bounds = { x: 0, y: 33, width: 1470, height: 857 }; + const window = { + role: "window", + name: `${title} - ${suffix}`, + bounds, + tree: async () => ({ name: title, children: [{ name: "New issue", children: [] }] }), + children: async () => [{ role: "button", name: "New issue", bounds, children: async () => [] }], + }; + accessibilityByPidMock.mockReset().mockImplementation(async (pid: number) => ({ + children: async () => (pid === 42 ? [window] : []), + })); + + const context = await readAccessibleWindowContext( + { title, bounds, owner: { processId: 42, bundleId } }, + "darwin", + title, + ); + + assert.equal(context?.accessibleText, `${title}\nNew issue`); + assert.equal(context?.accessibility?.format, "element-tree"); + assert.equal( + context?.accessibility?.format === "element-tree" + ? context.accessibility.root.children[0]?.name + : undefined, + "New issue", + ); +}); + +it("does not borrow another Chrome process context when the captured PID has no windows", async () => { + const title = "Issues · pingdotgg/t3code"; + const bounds = { x: 0, y: 33, width: 1470, height: 857 }; + const otherWindow = { + role: "window", + name: `${title} - Google Chrome – Profile`, + bounds, + tree: async () => ({ + name: title, + children: [{ name: "Other process private text", children: [] }], + }), + children: async () => [], + }; + accessibilityByPidMock.mockReset().mockResolvedValue({ + pid: 42, + children: async () => [], + }); + accessibilityListMock.mockResolvedValue([ + { pid: 43, bundleId: "com.google.Chrome", children: async () => [otherWindow] }, + ]); + + assert.isUndefined( + await readAccessibleWindowContext( + { title, bounds, owner: { processId: 42, bundleId: "com.google.Chrome" } }, + "darwin", + title, + ), + ); +}); + it("reads Flatpak app text from the AT-SPI proxy when the compositor PID misses", async () => { vi.stubEnv("XDG_SESSION_TYPE", "wayland"); const bounds = { x: 0, y: 0, width: 1_920, height: 1_048 }; @@ -2859,99 +1724,6 @@ it("does not guess GTK4 app text when two unnamed PID windows share the captured } }); -it.each([20, 1_350, 2_999])( - "includes accessibility text as soon as a %d ms read completes", - async (duration) => { - vi.useFakeTimers(); - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - const tree = Promise.withResolvers<{ value: string; children: Array }>(); - const started = Promise.withResolvers(); - accessibilityByPidMock.mockReset().mockResolvedValue({ - children: async () => [ - { - name: "Mozilla Firefox", - bounds: { x: 0, y: 0, width: 1_373, height: 928 }, - tree: () => { - started.resolve(); - return tree.promise; - }, - }, - ], - }); - - try { - const result = readAccessibleWindowText( - { - title: "Mozilla Firefox", - owner: { processId: 42 }, - bounds: { x: 67, y: 32, width: 1_373, height: 928 }, - }, - "linux", - "Mozilla Firefox", - ); - await started.promise; - await vi.advanceTimersByTimeAsync(duration); - tree.resolve({ value: "Firefox page text", children: [] }); - - assert.strictEqual(await result, "Firefox page text"); - assert.strictEqual(vi.getTimerCount(), 0); - } finally { - tree.resolve({ value: "", children: [] }); - await vi.advanceTimersByTimeAsync(0); - vi.useRealTimers(); - vi.unstubAllEnvs(); - } - }, -); - -it("falls back to completed flat text when rich traversal reaches the deadline", async () => { - vi.useFakeTimers(); - const richChildren = Promise.withResolvers>(); - const richStarted = Promise.withResolvers(); - accessibilityByPidMock.mockReset().mockResolvedValue({ - children: async () => [ - { - role: "window", - name: "Editor", - bounds: { x: 0, y: 0, width: 800, height: 600 }, - tree: async () => ({ value: "Complete flat text", children: [] }), - children: () => { - richStarted.resolve(); - return richChildren.promise; - }, - }, - ], - }); - - try { - const result = readAccessibleWindowContext( - { - title: "Editor", - owner: { processId: 42 }, - bounds: { x: 0, y: 0, width: 800, height: 600 }, - }, - "darwin", - "Editor", - { width: 1_600, height: 1_200 }, - ); - await richStarted.promise; - await vi.advanceTimersByTimeAsync(3_000); - - assert.deepEqual(await result, { - accessibleText: "Complete flat text", - accessibility: { - format: "flat-text", - text: "Complete flat text", - truncated: false, - }, - }); - } finally { - richChildren.resolve([]); - await vi.advanceTimersByTimeAsync(0); - vi.useRealTimers(); - } -}); - it("keeps a truncated element tree when the flat text read fails", async () => { const bounds = { x: 0, y: 0, width: 800, height: 600 }; accessibilityByPidMock.mockReset().mockResolvedValue({ @@ -2990,375 +1762,46 @@ it("keeps a truncated element tree when the flat text read fails", async () => { ); }); -it("keeps a truncated tree when flat text would not recover any text", async () => { - const bounds = { x: 0, y: 0, width: 800, height: 600 }; - accessibilityByPidMock.mockReset().mockResolvedValue({ - children: async () => [ - { - role: "window", - name: "Editor", - bounds, - tree: async () => ({ name: "Editor", children: [{ name: "Help", children: [] }] }), - children: async () => [ - { - role: "button", - name: "Help", - description: "Help text ".repeat(250), - bounds, - children: async () => [], - }, - ], - }, - ], - }); - - const result = await readAccessibleWindowContext( - { title: "Editor", bounds, owner: { processId: 123 } }, - "darwin", - "Editor", - ); - assert.equal(result?.accessibility?.format, "element-tree"); - assert.isTrue(result?.accessibility?.truncated); - assert.include( - result?.accessibility?.format === "element-tree" - ? result.accessibility.root.children[0]?.description - : undefined, - "Help text", - ); -}); - -it("times out after three seconds without overlapping the outstanding accessibility read", async () => { - vi.useFakeTimers(); - accessibilityByPidMock.mockReset(); - const read = Promise.withResolvers<{ children: () => Promise> }>(); - const started = Promise.withResolvers(); - accessibilityByPidMock.mockImplementationOnce(() => { - started.resolve(); - return read.promise; - }); - const active = { - title: "main.ts", - owner: { processId: 42 }, - bounds: { x: 0, y: 0, width: 800, height: 600 }, - } satisfies SnapShotAccessibility.AccessibleWindowIdentity; - - try { - const first = readAccessibleWindowText(active, "darwin", "main.ts"); - let settled = false; - void first.then(() => { - settled = true; - }); - await started.promise; - await vi.advanceTimersByTimeAsync(2_999); - assert.isFalse(settled); - await vi.advanceTimersByTimeAsync(1); - assert.isUndefined(await first); - assert.strictEqual(vi.getTimerCount(), 0); - assert.isUndefined(await readAccessibleWindowText(active, "darwin", "main.ts")); - assert.strictEqual(accessibilityByPidMock.mock.calls.length, 1); - - read.resolve({ children: async () => [] }); - await vi.advanceTimersByTimeAsync(0); - accessibilityByPidMock.mockResolvedValueOnce({ children: async () => [] }); - assert.isUndefined(await readAccessibleWindowText(active, "darwin", "main.ts")); - assert.strictEqual(accessibilityByPidMock.mock.calls.length, 2); - } finally { - read.resolve({ children: async () => [] }); - await vi.advanceTimersByTimeAsync(0); - vi.useRealTimers(); - } -}); - -it("uses native opacity for a short-lived flash", async () => { - vi.useFakeTimers(); - flashWindows.length = 0; - const flash = new DesktopSnapShot.SnapShotFlash(); - const bounds = { x: 10, y: 20, width: 800, height: 600 }; - - try { - await flash.showAnimated(bounds); - - assert.lengthOf(flashWindows, 1); - assert.strictEqual(flashWindows[0]?.kind, "base"); - assert.strictEqual(flashWindows[0]?.options.transparent, false); - assert.strictEqual(flashWindows[0]?.loadCount, 0); - assert.deepEqual(flashWindows[0]?.bounds, bounds); - assert.strictEqual(flashWindows[0]?.showCount, 1); - assert.lengthOf(flashWindows[0]?.scripts ?? [], 0); - await vi.advanceTimersByTimeAsync(180); - assert.isTrue(flashWindows[0]?.destroyed); - assert.isAbove(flashWindows[0]?.opacities.length ?? 0, 1); - assert.strictEqual(vi.getTimerCount(), 0); - } finally { - vi.useRealTimers(); - } -}); - -it.effect("does not request permissions or create the flash during desktop startup", () => { - flashWindows.length = 0; - accessibilityTrustedMock.mockReset().mockReturnValue(false); - mediaAccessStatusMock.mockReset().mockReturnValue("not-determined"); - getSourcesMock.mockReset().mockResolvedValue([]); - openExternalMock.mockClear(); - const settings = { - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotFlash: true, - }; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.initialize; - assert.lengthOf(flashWindows, 0); - assert.deepEqual(accessibilityTrustedMock.mock.calls, [[false]]); - assert.lengthOf(getSourcesMock.mock.calls, 0); - assert.lengthOf(openExternalMock.mock.calls, 0); - }), - ).pipe(Effect.provide(layerTest("darwin", {}, Option.some(settings)))); -}); - -it.effect("keeps snapshots disabled when client settings cannot be read at startup", () => { - const readError = new DesktopClientSettings.DesktopClientSettingsReadError({ - operation: "read-file", - path: "/state/client-settings.json", - cause: new Error("unavailable"), - }); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.initialize; - assert.isFalse((yield* service.state).shortcutRegistered); - }), - ).pipe(Effect.provide(layerTest("win32", {}, Option.none(), Effect.fail(readError)))); -}); - -it.effect("does not request macOS permissions while synchronizing enabled settings", () => { - accessibilityTrustedMock.mockReset().mockReturnValue(false); - mediaAccessStatusMock.mockReset().mockReturnValue("not-determined"); - getSourcesMock.mockReset().mockResolvedValue([]); - openExternalMock.mockClear(); - const settings = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true }; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(settings); - - assert.deepEqual(accessibilityTrustedMock.mock.calls, [[false]]); - assert.lengthOf(getSourcesMock.mock.calls, 0); - assert.lengthOf(openExternalMock.mock.calls, 0); - }), - ).pipe(Effect.provide(layerTest("darwin"))); -}); - -it.effect("requests macOS permissions only for an explicit enable action", () => { - accessibilityTrustedMock.mockReset().mockReturnValue(false); - mediaAccessStatusMock.mockReset().mockReturnValue("not-determined"); - getSourcesMock.mockReset().mockResolvedValue([]); - openExternalMock.mockClear(); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.requestPermissions(true); - - assert.deepEqual(accessibilityTrustedMock.mock.calls, [[true]]); - assert.lengthOf(getSourcesMock.mock.calls, 1); - assert.lengthOf(openExternalMock.mock.calls, 1); - }), - ).pipe(Effect.provide(layerTest("darwin"))); -}); - -it.effect("does not request macOS accessibility permission when capture data is disabled", () => { - accessibilityTrustedMock.mockReset().mockReturnValue(false); - mediaAccessStatusMock.mockReset().mockReturnValue("not-determined"); - getSourcesMock.mockReset().mockResolvedValue([]); - openExternalMock.mockClear(); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.requestPermissions(false); - - assert.lengthOf(accessibilityTrustedMock.mock.calls, 0); - assert.lengthOf(getSourcesMock.mock.calls, 1); - assert.lengthOf(openExternalMock.mock.calls, 1); - }), - ).pipe(Effect.provide(layerTest("darwin"))); -}); - -it.effect("reports macOS permission status and requests each permission on its own", () => { - accessibilityTrustedMock.mockReset().mockReturnValue(false); - mediaAccessStatusMock.mockReset().mockReturnValue("granted"); - getSourcesMock.mockReset().mockResolvedValue([]); - openExternalMock.mockClear(); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const state = yield* service.state; - assert.deepEqual(state.macPermissions, { screenRecording: true, accessibility: false }); - - yield* service.setup("allow-accessibility"); - assert.deepEqual(accessibilityTrustedMock.mock.calls.at(-1), [true]); - assert.lengthOf(getSourcesMock.mock.calls, 0); - - mediaAccessStatusMock.mockReturnValue("not-determined"); - yield* service.setup("allow-screen-recording"); - assert.lengthOf(getSourcesMock.mock.calls, 1); - assert.lengthOf(openExternalMock.mock.calls, 1); - }), - ).pipe(Effect.provide(layerTest("darwin"))); -}); - it.effect("flags revoked macOS permissions on read and re-registers once they return", () => { - accessibilityTrustedMock.mockReset().mockReturnValue(true); - mediaAccessStatusMock.mockReset().mockReturnValue("granted"); - registerShortcutMock.mockReset().mockReturnValue(true); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure({ ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true }); - assert.isNull((yield* service.state).message); - - mediaAccessStatusMock.mockReturnValue("denied"); - const revoked = yield* service.state; - assert.equal( - revoked.message, - "Allow Screen Recording in System Settings, then restart T3 Code.", - ); - assert.deepEqual(revoked.macPermissions, { screenRecording: false, accessibility: true }); - - accessibilityTrustedMock.mockReturnValue(false); - yield* service.configure({ - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotIncludeAccessibility: false, - }); - const blocked = yield* service.state; - assert.equal( - blocked.message, - "Allow Screen Recording in System Settings, then restart T3 Code.", - ); - assert.isFalse(blocked.shortcutRegistered); - - mediaAccessStatusMock.mockReturnValue("granted"); - const recovered = yield* service.state; - assert.isNull(recovered.message); - assert.isTrue(recovered.shortcutRegistered); - }), - ).pipe(Effect.provide(layerTest("darwin"))); -}); - -it.effect("rejects macOS permission actions off macOS and omits macPermissions there", () => { - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const state = yield* service.state; - assert.isUndefined(state.macPermissions); - assert.isTrue(state.windows); - const error = yield* service.setup("allow-screen-recording").pipe(Effect.flip); - assert.equal(error.reason, "unsupported-session"); - }), - ).pipe(Effect.provide(layerTest("win32"))); -}); - -it.effect("registers macOS capture without accessibility permission when data is disabled", () => { - accessibilityTrustedMock.mockReset().mockReturnValue(false); + accessibilityTrustedMock.mockReset().mockReturnValue(true); mediaAccessStatusMock.mockReset().mockReturnValue("granted"); registerShortcutMock.mockReset().mockReturnValue(true); return Effect.scoped( Effect.gen(function* () { const service = yield* DesktopSnapShot.make; + yield* service.configure({ ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true }); + assert.isNull((yield* service.state).message); + + mediaAccessStatusMock.mockReturnValue("denied"); + const revoked = yield* service.state; + assert.equal( + revoked.message, + "Allow Screen Recording in System Settings, then restart T3 Code.", + ); + assert.deepEqual(revoked.macPermissions, { screenRecording: false, accessibility: true }); + + accessibilityTrustedMock.mockReturnValue(false); yield* service.configure({ ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true, snapShotIncludeAccessibility: false, }); + const blocked = yield* service.state; + assert.equal( + blocked.message, + "Allow Screen Recording in System Settings, then restart T3 Code.", + ); + assert.isFalse(blocked.shortcutRegistered); - assert.lengthOf(accessibilityTrustedMock.mock.calls, 0); - assert.isTrue((yield* service.state).shortcutRegistered); + mediaAccessStatusMock.mockReturnValue("granted"); + const recovered = yield* service.state; + assert.isNull(recovered.message); + assert.isTrue(recovered.shortcutRegistered); }), ).pipe(Effect.provide(layerTest("darwin"))); }); -it.effect("starts the Shift listener outside the Electron main process", () => { - shortcutForkArgs.length = 0; - shortcutForkOptions.length = 0; - shortcutProcesses.length = 0; - const settings = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true }; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(settings); - const state = yield* service.state; - - assert.isTrue(state.shortcutRegistered); - assert.lengthOf(shortcutProcesses, 1); - assert.deepEqual(shortcutForkArgs[0], ["shift"]); - assert.strictEqual(shortcutForkOptions[0]?.env?.ELECTRON_RUN_AS_NODE, "1"); - - shortcutProcesses[0]?.emit("exit", 1); - yield* Effect.promise(() => new Promise((resolve) => queueMicrotask(resolve))); - assert.isFalse((yield* service.state).shortcutRegistered); - }), - ).pipe(Effect.provide(layerTest("win32"))); -}); - -it.effect("passes the configured modifier pair to the listener process", () => { - shortcutForkArgs.length = 0; - shortcutProcesses.length = 0; - const settings = { - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotShortcut: { kind: "modifier-pair", modifier: "meta" }, - } satisfies ClientSettings; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(settings); - const state = yield* service.state; - - assert.isTrue(state.shortcutRegistered); - assert.deepEqual(shortcutForkArgs[0], ["meta"]); - }), - ).pipe(Effect.provide(layerTest("win32"))); -}); - -it.effect("registers a configured key chord instead of the Shift listener", () => { - registerShortcutMock.mockReset().mockReturnValue(true); - shortcutProcesses.length = 0; - const settings = { - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotShortcut: { - key: "k", - metaKey: false, - ctrlKey: true, - shiftKey: false, - altKey: true, - modKey: false, - }, - }; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(settings); - - assert.isTrue((yield* service.state).shortcutRegistered); - assert.strictEqual(registerShortcutMock.mock.calls[0]?.[0], "Control+Alt+K"); - assert.lengthOf(shortcutProcesses, 0); - }), - ).pipe(Effect.provide(layerTest("win32"))); -}); - it.effect("an unrelated client-setting change keeps the registered shortcut", () => { registerShortcutMock.mockReset().mockReturnValue(true); unregisterShortcutMock.mockReset(); @@ -3418,133 +1861,6 @@ it.effect("capture fails closed while snapshots are disabled", () => { ).pipe(Effect.provide(layerTest("win32"))); }); -it.effect("keeps shortcut registration errors off the capture status", () => { - registerShortcutMock.mockReset().mockReturnValue(false); - const settings = { - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotShortcut: { - key: "k", - metaKey: false, - ctrlKey: true, - shiftKey: false, - altKey: true, - modKey: false, - }, - }; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(settings); - - const state = yield* service.state; - assert.isNull(state.message); - assert.equal( - state.shortcutMessage, - "This shortcut is already used by the system or another app.", - ); - }), - ).pipe(Effect.provide(layerTest("win32"))); -}); - -it.effect("uses an external Niri shortcut without registering an Electron accelerator", () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - vi.stubEnv("XDG_CURRENT_DESKTOP", "niri"); - vi.stubEnv("NIRI_SOCKET", "/test/niri.sock"); - registerShortcutMock.mockReset(); - niriShortcutStopMock.mockReset(); - niriShortcutMock.mockReset().mockResolvedValue(niriShortcutStopMock); - const settings = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true }; - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(settings); - const state = yield* service.state; - assert.isFalse(state.shortcutRegistered); - assert.include(state.shortcutBinding, "gdbus"); - assert.match(state.shortcutBinding ?? "", /^Ctrl\+Shift\+2 repeat=false \{/); - assert.include(state.shortcutMessage, "Niri config"); - assert.isTrue(state.shortcutActionRegistered); - assert.lengthOf(registerShortcutMock.mock.calls, 0); - assert.lengthOf(niriShortcutMock.mock.calls, 1); - assert.isFalse((yield* service.checkShortcut(settings.snapShotShortcut)).available); - yield* Effect.promise(async () => { - await niriShortcutMock.mock.calls[0]![1](); - }); - assert.isTrue((yield* service.state).shortcutVerified); - yield* service.configure({ ...settings, snapShotEnabled: false }); - assert.lengthOf(niriShortcutStopMock.mock.calls, 1); - assert.isUndefined((yield* service.state).shortcutBinding); - }), - ).pipe( - Effect.provide(layerTest("linux")), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); -}); - -it.effect("defers ordinary Wayland shortcut registration until settings are applied", () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - registerShortcutMock.mockReset().mockReturnValue(false); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const conflict = yield* service.checkShortcut({ - key: "c", - metaKey: false, - ctrlKey: true, - shiftKey: false, - altKey: false, - modKey: false, - }); - const available = yield* service.checkShortcut({ - key: "2", - metaKey: false, - ctrlKey: true, - shiftKey: true, - altKey: false, - modKey: false, - }); - assert.isFalse(conflict.available); - assert.isTrue(available.available); - assert.match(available.message ?? "", /desktop will confirm/); - - const pair = yield* service.checkShortcut({ - kind: "modifier-pair", - modifier: "meta", - }); - assert.isFalse(pair.available); - assert.match(pair.message ?? "", /Modifier-pair shortcuts aren't available/); - assert.lengthOf(registerShortcutMock.mock.calls, 0); - }), - ).pipe( - Effect.provide(layerTest("linux")), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); -}); - -it.effect.each(["GNOME", "KDE", "niri", "Hyprland"] as const)( - "identifies %s in setup even when its capability check fails", - (desktop) => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - vi.stubEnv("XDG_CURRENT_DESKTOP", desktop); - linuxBackendMock.mockRejectedValueOnce(new Error("Capability check failed")); - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const state = yield* service.state; - assert.equal(state.linuxDesktop, desktop.toLowerCase()); - assert.equal(state.message, "Capability check failed"); - assert.isFalse(state.shortcutVerified); - }), - ).pipe( - Effect.provide(layerTest("linux")), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); - }, -); - it.effect( "keeps saved preferences but rechecks access and shortcut delivery after changing desktops", () => { @@ -3619,85 +1935,6 @@ it.effect( }, ); -it.effect("does not register a Wayland modifier-pair shortcut when enabled", () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - registerShortcutMock.mockReset().mockReturnValue(true); - const settings = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true }; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(settings); - - const state = yield* service.state; - assert.lengthOf(registerShortcutMock.mock.calls, 0); - assert.isFalse(state.shortcutRegistered); - assert.deepEqual(state.shortcut, DEFAULT_CLIENT_SETTINGS.snapShotShortcut); - assert.match(state.shortcutMessage ?? "", /Modifier-pair shortcuts aren't available/); - }), - ).pipe( - Effect.provide(layerTest("linux")), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); -}); - -it.effect("exposes pending portal permission and then the real assigned shortcut", () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - registerShortcutMock.mockReset().mockReturnValue(true); - nextPortalState.value = { - shortcutRegistered: false, - shortcutPending: true, - shortcutMessage: "Waiting for permission", - }; - const shortcut = { - key: "2", - metaKey: false, - ctrlKey: true, - shiftKey: true, - altKey: false, - modKey: false, - } as const; - const settings = { - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotShortcut: shortcut, - }; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(settings); - - const state = yield* service.state; - assert.lengthOf(registerShortcutMock.mock.calls, 0); - assert.isFalse(state.shortcutRegistered); - assert.isTrue(state.shortcutPending); - assert.deepEqual(state.shortcut, shortcut); - assert.equal(state.shortcutMessage, "Waiting for permission"); - - portalShortcutInstances[0]!.state = { - shortcutRegistered: true, - shortcutPending: false, - shortcutLabel: "Ctrl+Shift+7", - shortcutMessage: null, - }; - assert.equal((yield* service.state).shortcutLabel, "Ctrl+Shift+7"); - assert.isTrue((yield* service.state).shortcutRegistered); - portalShortcutInstances[0]!.state = { - shortcutRegistered: false, - shortcutPending: false, - shortcutMessage: "Permission denied", - }; - const failed = yield* service.state; - assert.isFalse(failed.shortcutRegistered); - assert.equal(failed.shortcutMessage, "Permission denied"); - }), - ).pipe( - Effect.provide(layerTest("linux")), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); -}); - it.effect("verifies only native shortcut delivery and captures normally", () => { vi.stubEnv("XDG_SESSION_TYPE", "wayland"); vi.stubEnv("XDG_CURRENT_DESKTOP", "test-desktop"); @@ -3854,58 +2091,6 @@ it.effect( }, ); -it.effect("advises about the system menu for a meta pair on Windows", () => - Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const result = yield* service.checkShortcut({ kind: "modifier-pair", modifier: "meta" }); - assert.isTrue(result.available); - assert.match(result.message ?? "", /Super \+ Super is observed/); - assert.match(result.message ?? "", /system's own menu/); - }), - ).pipe(Effect.provide(layerTest("win32"))), -); - -it.effect("probes macOS modifier pairs with the flags poller", () => { - spawnedPollers.length = 0; - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const result = yield* service.checkShortcut({ kind: "both-shift-keys" }); - assert.isTrue(result.available); - assert.match(result.message ?? "", /Shift \+ Shift is observed/); - assert.notMatch(result.message ?? "", /Input Monitoring/); - assert.lengthOf(spawnedPollers, 1); - assert.deepEqual(spawnedPollers[0]?.args.slice(-2), ["2", "4"]); - assert.strictEqual(spawnedPollers[0]?.kill.mock.calls.length, 1); - }), - ).pipe(Effect.provide(layerTest("darwin"))); -}); - -it.effect("registers macOS modifier pairs through the flags poller", () => { - spawnedPollers.length = 0; - shortcutProcesses.length = 0; - accessibilityTrustedMock.mockReturnValue(true); - mediaAccessStatusMock.mockReturnValue("granted"); - const settings = { - ...DEFAULT_CLIENT_SETTINGS, - snapShotShortcut: { kind: "modifier-pair", modifier: "meta" }, - } satisfies ClientSettings; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure({ ...settings, snapShotEnabled: true }); - const state = yield* service.state; - - assert.lengthOf(shortcutProcesses, 0); - assert.lengthOf(spawnedPollers, 1); - assert.deepEqual(spawnedPollers[0]?.args.slice(-2), ["8", "16"]); - assert.isTrue(state.shortcutRegistered); - }), - ).pipe(Effect.provide(layerTest("darwin"))); -}); - it.effect("waits to apply settings while permissions are pending", () => { accessibilityTrustedMock.mockReturnValue(true); mediaAccessStatusMock.mockReturnValueOnce("not-determined").mockReturnValue("granted"); @@ -3989,89 +2174,3 @@ it.effect.each([false, true])( ); }, ); - -const macTestCaptureLayer = layerTest("darwin", { - makeTempDirectoryScoped: () => Effect.succeed("/tmp/setup-test"), - readDirectory: () => Effect.succeed([]), -}); -const macTestCaptureWindow = { - platform: "macos", - id: 42, - title: "Setup", - owner: { name: "T3 Code", processId: 123, path: "/Applications/T3 Code.app" }, - bounds: { x: 0, y: 0, width: 800, height: 600 }, -}; - -it.effect("names the failed macOS window lookup without repeating what it printed", () => { - const script = 'ObjC.import("CoreGraphics"); function run() { return ""; }'; - const stderr = "execution error: Error: Not authorized to send Apple events. (-1743)"; - activeWindowMock - .mockReset() - .mockRejectedValue( - Object.assign( - new Error(`Command failed: /usr/bin/osascript -l JavaScript -e ${script}\n${stderr}`), - { cmd: `/usr/bin/osascript -l JavaScript -e ${script}`, code: 1, killed: false }, - ), - ); - macCaptureMock.mockReset(); - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const error = yield* service.setup("test-mac-capture").pipe(Effect.flip); - assert.equal( - error.message, - 'Could not capture a test snapshot. Finding the front window failed: osascript exited with code 1. macOS grants Screen Recording and Accessibility to each app separately. In System Settings, open Privacy & Security and check that "T3 Code (Dev)" is allowed for both.', - ); - assert.include(String(error.cause), stderr); - assert.equal(macCaptureMock.mock.calls.length, 0); - }), - ).pipe(Effect.provide(macTestCaptureLayer)); -}); - -it.effect("says when macOS has no window to test capture", () => { - activeWindowMock.mockReset().mockResolvedValue(undefined); - macCaptureMock.mockReset(); - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const error = yield* service.setup("test-mac-capture").pipe(Effect.flip); - assert.equal( - error.message, - "Could not capture a test snapshot. No window is available to capture.", - ); - assert.equal(macCaptureMock.mock.calls.length, 0); - }), - ).pipe(Effect.provide(macTestCaptureLayer)); -}); - -it.effect("says when the macOS test capture command timed out", () => { - activeWindowMock.mockReset().mockResolvedValue(macTestCaptureWindow); - macCaptureMock.mockReset().mockRejectedValue( - Object.assign(new Error("Command failed: /usr/sbin/screencapture -l 42 -o -x"), { - code: null, - killed: true, - signal: "SIGTERM", - }), - ); - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const error = yield* service.setup("test-mac-capture").pipe(Effect.flip); - assert.equal( - error.message, - 'Could not capture a test snapshot. Capturing the window timed out. macOS grants Screen Recording and Accessibility to each app separately. In System Settings, open Privacy & Security and check that "T3 Code (Dev)" is allowed for both.', - ); - }), - ).pipe(Effect.provide(macTestCaptureLayer)); -}); - -it.effect("rejects macOS test capture on other platforms", () => - Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const error = yield* service.setup("test-mac-capture").pipe(Effect.flip); - assert.equal(error.reason, "unsupported-session"); - assert.equal(error.message, "Test capture requires macOS."); - }), - ).pipe(Effect.provide(layerTest("win32"))), -); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 539d9019bd52..164c97b4f22f 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -289,11 +289,11 @@ function captureMode(platform: NodeJS.Platform): DesktopSnapShotState["mode"] { return platform === "darwin" || platform === "win32" ? "direct" : "unavailable"; } -export function shouldAnimateSnapShot(settings: SnapShotSystemAnimationSettings): boolean { +function shouldAnimateSnapShot(settings: SnapShotSystemAnimationSettings): boolean { return settings.shouldRenderRichAnimation && !settings.prefersReducedMotion; } -export function snapShotThumbnailSize(active: ActiveWindow | undefined): Electron.Size { +function snapShotThumbnailSize(active: ActiveWindow | undefined): Electron.Size { if (!active) return { width: 2_560, height: 1_600 }; return { width: Math.min(Math.max(active.bounds.width, 1), MAX_CAPTURE_WIDTH), @@ -301,7 +301,7 @@ export function snapShotThumbnailSize(active: ActiveWindow | undefined): Electro }; } -export function snapShotIconDataUrl( +function snapShotIconDataUrl( ...icons: ReadonlyArray ): string | undefined { const icon = icons.find((candidate): candidate is Electron.NativeImage => @@ -324,7 +324,7 @@ async function appFileIcon( return Electron.app.getFileIcon(path, { size: "normal" }).catch(() => undefined); } -export async function iconDataUrl( +async function iconDataUrl( source: { readonly appIcon?: Electron.NativeImage | null }, active: ActiveWindow | undefined, platform: NodeJS.Platform, @@ -665,7 +665,7 @@ export class SnapShotFlash { } } -export function snapShotFlashBounds( +function snapShotFlashBounds( active: ActiveWindow | undefined, platform: NodeJS.Platform, ): Electron.Rectangle { @@ -1708,8 +1708,8 @@ export const make = Effect.gen(function* () { ), Effect.mapError((cause) => new DesktopSnapShotError({ operation: "list-pending", cause })), ), - read: (id) => - Effect.gen(function* () { + read: Effect.fn("DesktopSnapShot.read")( + function* (id) { const metadata = yield* fileSystem .readFileString(path.join(captureDirectory, `${id}.json`)) .pipe(Effect.flatMap(decodePendingCaptureJson)); @@ -1718,11 +1718,14 @@ export const make = Effect.gen(function* () { ...metadata, dataUrl: `data:image/png;base64,${Base64.encode(png)}`, }; - }).pipe( - Effect.mapError( - (cause) => new DesktopSnapShotError({ operation: "read", captureId: id, cause }), + }, + (effect, id) => + effect.pipe( + Effect.mapError( + (cause) => new DesktopSnapShotError({ operation: "read", captureId: id, cause }), + ), ), - ), + ), setAnimationDestination: (id, destination) => Effect.promise(async () => { if (linuxFeedback?.id === id && destination.relativeFrame) { diff --git a/apps/desktop/src/snapShot/GnomeCaptureSetup.test.ts b/apps/desktop/src/snapShot/GnomeCaptureSetup.test.ts index af2052c6a03a..fd0bfdfd15dd 100644 --- a/apps/desktop/src/snapShot/GnomeCaptureSetup.test.ts +++ b/apps/desktop/src/snapShot/GnomeCaptureSetup.test.ts @@ -5,11 +5,7 @@ import * as NodePath from "node:path"; import * as NodeEvents from "node:events"; import type { Message, MessageBus } from "dbus-next"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; -import { - GnomeCaptureSetup, - installGnomeCaptureBundle, - isGnomeCaptureSession, -} from "./GnomeCaptureSetup.ts"; +import { GnomeCaptureSetup, installGnomeCaptureBundle } from "./GnomeCaptureSetup.ts"; import { GNOME_CAPTURE_FILES, GNOME_CAPTURE_UUID } from "./gnomeCaptureBundle.ts"; let dataHome: string; @@ -117,13 +113,9 @@ it("refuses to replace symlinks or downgrade a newer extension", async () => { }); it.each([ - [{}, "not-installed"], - [{ state: 2, version: 3 }, "disabled"], [{ state: 1, version: 3 }, "enabled"], - [{ state: 1, version: 1 }, "update-required"], [{ state: 1, version: 2, shell: "51.0" }, "update-required"], [{ state: 2, version: 3, enabled: false }, "extensions-disabled"], - [{ state: 3, version: 3, error: "Load failed" }, "error"], [{ shell: "52.0" }, "unsupported"], ] as const)("reports GNOME setup state %j", async (options, status) => { const { setup, call } = fixture(options); @@ -147,31 +139,6 @@ it("requires login for local installs and updates until the new version is loade setup.close(); }); -it("installs only on explicit action without invoking remote installation", async () => { - const { setup, call } = fixture(); - await setup.perform("install-extension"); - expect(await NodeFSP.readdir(installedPath())).toHaveLength(GNOME_CAPTURE_FILES.length); - expect( - call.mock.calls.every(([message]) => ["GetAll", "GetExtensionInfo"].includes(message.member)), - ).toBe(true); - setup.close(); -}); - -it("enables and disables only the capture UUID", async () => { - const { setup, call } = fixture({ state: 2, version: 3 }); - await setup.perform("enable-extension"); - await setup.perform("disable-extension"); - for (const member of ["EnableExtension", "DisableExtension"]) { - expect(call.mock.calls.find(([message]) => message.member === member)?.[0]).toMatchObject({ - destination: "org.gnome.Shell", - path: "/org/gnome/Shell", - signature: "s", - body: [GNOME_CAPTURE_UUID], - }); - } - setup.close(); -}); - it("never changes the global user-extensions preference", async () => { const { setup, call } = fixture({ state: 2, version: 3, enabled: false }); await expect(setup.perform("enable-extension")).rejects.toThrow("GNOME has disabled"); @@ -186,9 +153,3 @@ it("surfaces desktop rejection and disconnect as actionable failures", async () expect(await setup.state()).toEqual({ status: "error", message: "Session bus disconnected" }); setup.close(); }); - -it("does not offer host extension installation in another desktop or sandbox", () => { - expect(isGnomeCaptureSession({ XDG_CURRENT_DESKTOP: "ubuntu:GNOME" })).toBe(true); - expect(isGnomeCaptureSession({ XDG_CURRENT_DESKTOP: "niri", GDMSESSION: "gnome" })).toBe(false); - expect(isGnomeCaptureSession({ XDG_CURRENT_DESKTOP: "GNOME", FLATPAK_ID: "com.t3" })).toBe(false); -}); diff --git a/apps/desktop/src/snapShot/GnomeCaptureSetup.ts b/apps/desktop/src/snapShot/GnomeCaptureSetup.ts index cc4ada0d57ab..5979bf3b000c 100644 --- a/apps/desktop/src/snapShot/GnomeCaptureSetup.ts +++ b/apps/desktop/src/snapShot/GnomeCaptureSetup.ts @@ -7,7 +7,6 @@ import * as Schema from "effect/Schema"; import type { DesktopCaptureExtensionState } from "@t3tools/contracts"; import { GNOME_CAPTURE_FILES, GNOME_CAPTURE_UUID } from "./gnomeCaptureBundle.ts"; -export { isGnomeCaptureSession } from "./linuxCaptureSession.ts"; const SHELL = "org.gnome.Shell"; const SHELL_PATH = "/org/gnome/Shell"; diff --git a/apps/desktop/src/snapShot/HyprlandSnapShot.test.ts b/apps/desktop/src/snapShot/HyprlandSnapShot.test.ts index fe55319ecea4..525e57b34938 100644 --- a/apps/desktop/src/snapShot/HyprlandSnapShot.test.ts +++ b/apps/desktop/src/snapShot/HyprlandSnapShot.test.ts @@ -17,7 +17,6 @@ import { captureHyprlandWindow, hyprlandCaptureExecutable, hyprlandCaptureShortcut, - isHyprlandCaptureSession, } from "./HyprlandSnapShot.ts"; let directory: string; @@ -67,22 +66,6 @@ beforeEach(async () => { afterEach(async () => { await NodeFSP.rm(directory, { recursive: true, force: true }); }); - -it("does not select Hyprland from a stale socket environment or sandbox", () => { - expect(isHyprlandCaptureSession({ XDG_CURRENT_DESKTOP: "Hyprland" })).toBe(true); - expect( - isHyprlandCaptureSession({ XDG_CURRENT_DESKTOP: "KDE", HYPRLAND_INSTANCE_SIGNATURE: "old" }), - ).toBe(false); - expect(isHyprlandCaptureSession({ XDG_CURRENT_DESKTOP: "Hyprland", FLATPAK_ID: "app" })).toBe( - false, - ); - expect(isHyprlandCaptureSession({ XDG_CURRENT_DESKTOP: "Hyprland", SNAP: "app" })).toBe(false); -}); -it("discovery neither installs a helper nor requests a screenshot", async () => { - expect((await setup.state()).status).toBe("not-installed"); - expect(execute).not.toHaveBeenCalled(); - await expect(NodeFSP.stat(paths.dataHome)).rejects.toMatchObject({ code: "ENOENT" }); -}); it("installs offline at a stable executable path and probes only capabilities", async () => { await setup.perform("install-hyprland-helper"); expect((await NodeFSP.stat(hyprlandCaptureExecutable(paths))).mode & 0o777).toBe(0o755); diff --git a/apps/desktop/src/snapShot/KdeSnapShot.test.ts b/apps/desktop/src/snapShot/KdeSnapShot.test.ts index 2093be868e67..199af10e8ff9 100644 --- a/apps/desktop/src/snapShot/KdeSnapShot.test.ts +++ b/apps/desktop/src/snapShot/KdeSnapShot.test.ts @@ -13,13 +13,7 @@ vi.mock("electron", () => ({ createFromBuffer: () => ({ isEmpty: () => false, getSize: () => ({ width: 2, height: 1 }) }), }, })); -import { - KdeCaptureSetup, - captureKdeWindow, - isKdeCaptureSession, - kdeCaptureDesktopEntry, - kdeCapturePaths, -} from "./KdeSnapShot.ts"; +import { KdeCaptureSetup, captureKdeWindow, kdeCapturePaths } from "./KdeSnapShot.ts"; let directory: string; let paths: { bundle: string; dataHome: string }; @@ -73,48 +67,6 @@ afterEach(async () => { await NodeFSP.rm(directory, { recursive: true, force: true }); }); -it("detects KDE only outside a sandbox", () => { - expect(isKdeCaptureSession({ XDG_CURRENT_DESKTOP: "KDE" })).toBe(true); - expect(isKdeCaptureSession({ XDG_CURRENT_DESKTOP: "GNOME" })).toBe(false); - expect(isKdeCaptureSession({ XDG_CURRENT_DESKTOP: "KDE", FLATPAK_ID: "app" })).toBe(false); - expect(isKdeCaptureSession({ XDG_CURRENT_DESKTOP: "KDE", SNAP: "/snap/app" })).toBe(false); -}); - -it("does not install or request screenshots during initial discovery", async () => { - expect((await setup.state()).status).toBe("not-installed"); - expect(execute).not.toHaveBeenCalled(); - await expect(NodeFSP.stat(paths.dataHome)).rejects.toMatchObject({ code: "ENOENT" }); -}); - -it("installs offline at a stable path and verifies permissions using the installed identity", async () => { - await setup.perform("install-kde-helper"); - const { executable, desktop } = kdeCapturePaths(paths); - expect(await NodeFSP.readFile(executable, "utf8")).toBe("bundled executable"); - expect((await NodeFSP.stat(executable)).mode & 0o777).toBe(0o755); - expect(await NodeFSP.readFile(desktop, "utf8")).toBe(kdeCaptureDesktopEntry(executable)); - expect(kdeCaptureDesktopEntry(executable)).toContain(`Exec="${executable}" check`); - expect((await setup.state()).status).toBe("ready"); - expect((await setup.state()).feedbackAvailable).toBe(true); - expect(execute.mock.calls.map(([file, args]) => [file, args])).toEqual([ - ["kbuildsycoca6", ["--noincremental"]], - [ - "systemd-run", - [ - "--user", - "--quiet", - "--wait", - "--collect", - "--pipe", - "--service-type=exec", - "kbuildsycoca6", - "--noincremental", - ], - ], - [executable, ["check"]], - [executable, ["check"]], - ]); -}); - it("does not mistake installed files for KDE authorization, or fall back to a picker on denial", async () => { await setup.perform("install-kde-helper"); denial = true; diff --git a/apps/desktop/src/snapShot/KdeSnapShot.ts b/apps/desktop/src/snapShot/KdeSnapShot.ts index 5a90aa818fac..bfd6b2984147 100644 --- a/apps/desktop/src/snapShot/KdeSnapShot.ts +++ b/apps/desktop/src/snapShot/KdeSnapShot.ts @@ -29,7 +29,7 @@ export function kdeCapturePaths(paths: KdeCapturePaths) { }; } -export function kdeCaptureDesktopEntry(executable: string): string { +function kdeCaptureDesktopEntry(executable: string): string { return [ "[Desktop Entry]", "Type=Application", diff --git a/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts b/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts deleted file mode 100644 index 1cb75134ad07..000000000000 --- a/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Isolated D-Bus integration fixture, never the user's session bus. -import * as NodeChildProcess from "node:child_process"; -import * as NodeEvents from "node:events"; -import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import * as NodeReadline from "node:readline"; -import * as NodeURL from "node:url"; -import { Message, NameFlag, Variant, sessionBus, type MessageBus } from "dbus-next"; -import { expect, it, vi } from "vite-plus/test"; - -vi.mock("electron", () => ({ - nativeImage: { - createFromBuffer: () => ({ - isEmpty: () => false, - getSize: () => ({ width: 800, height: 600 }), - }), - }, -})); -import { LinuxCaptureConnection } from "./LinuxSnapShot.ts"; -import { startNiriCaptureShortcut } from "./NiriCaptureShortcut.ts"; -import { GnomeCaptureSetup } from "./GnomeCaptureSetup.ts"; -import { GNOME_CAPTURE_UUID } from "./gnomeCaptureBundle.ts"; - -const hasDbus = - NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0 && - NodeChildProcess.spawnSync("gdbus", ["help"]).status === 0; - -it.runIf(hasDbus)("captures through real D-Bus marshalling on a private bus", async () => { - const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-private-dbus-")); - let daemon: NodeChildProcess.ChildProcess | undefined; - let server: MessageBus | undefined; - const clients: LinuxCaptureConnection[] = []; - let stopNiriShortcut: (() => void) | undefined; - try { - vi.stubEnv("XDG_CURRENT_DESKTOP", "GNOME"); - vi.stubEnv("FLATPAK_ID", ""); - vi.stubEnv("SNAP", ""); - daemon = NodeChildProcess.spawn( - "dbus-daemon", - [ - "--session", - "--nofork", - "--nopidfile", - "--print-address", - `--address=unix:path=${NodePath.join(directory, "bus")}`, - ], - { stdio: ["ignore", "pipe", "pipe"] }, - ); - const lines = NodeReadline.createInterface({ input: daemon.stdout! }); - const [address] = await Promise.race([ - NodeEvents.EventEmitter.once(lines, "line"), - NodeEvents.EventEmitter.once(daemon, "exit").then(() => { - throw new Error("Private D-Bus daemon failed to start."); - }), - ]); - lines.close(); - server = sessionBus({ busAddress: String(address) }); - server.on("error", () => undefined); - await server.requestName("org.freedesktop.portal.Desktop", NameFlag.DO_NOT_QUEUE); - await server.requestName("org.gnome.Shell.Extensions.T3SnapShot", NameFlag.DO_NOT_QUEUE); - await server.requestName("org.gnome.Shell", NameFlag.DO_NOT_QUEUE); - await server.requestName("org.kde.KWin.ScreenShot2", NameFlag.DO_NOT_QUEUE); - const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0]); - const imagePath = NodePath.join(directory, "image.png"); - await NodeFSP.writeFile(imagePath, png); - let portalVersion = 3; - let extensionVersion = 1; - let feedbackArgs: unknown[] | undefined; - let activateTitle: unknown; - let animateFrame: unknown[] | undefined; - let target: unknown; - let clientName: string | undefined; - let shellExtensionState = 2; - server.addMethodHandler((message: Message) => { - if (message.member === "Register") { - server!.send(Message.newMethodReturn(message)); - } else if (message.member === "GetAll") { - const properties = - message.body[0] === "org.gnome.Shell.Extensions" - ? { - ShellVersion: new Variant("s", "50.0"), - UserExtensionsEnabled: new Variant("b", true), - } - : message.body[0] === "org.kde.KWin.ScreenShot2" - ? { Version: new Variant("u", 5) } - : message.body[0] === "org.freedesktop.portal.Screenshot" - ? { - version: new Variant("u", portalVersion), - AvailableTargets: new Variant("u", 8), - } - : { Version: new Variant("u", extensionVersion) }; - server!.send(Message.newMethodReturn(message, "a{sv}", [properties])); - } else if (message.member === "GetExtensionInfo") { - server!.send( - Message.newMethodReturn(message, "a{sv}", [ - { state: new Variant("d", shellExtensionState), version: new Variant("d", 3) }, - ]), - ); - } else if (message.member === "EnableExtension" || message.member === "DisableExtension") { - expect(message.body).toEqual([GNOME_CAPTURE_UUID]); - shellExtensionState = message.member === "EnableExtension" ? 1 : 2; - server!.send(Message.newMethodReturn(message, "b", [true])); - } else if (message.member === "Screenshot") { - const options = message.body[1] as Record>; - target = options.target?.value; - const path = `/org/freedesktop/portal/desktop/request/${message.sender.slice(1).replaceAll(".", "_")}/${options.handle_token?.value}`; - // The real transport also has to handle Response before the method reply. - const signal = Message.newSignal( - path, - "org.freedesktop.portal.Request", - "Response", - "ua{sv}", - [0, { uri: new Variant("s", NodeURL.pathToFileURL(imagePath).href) }], - ); - signal.destination = message.sender; - server!.send(signal); - server!.send(Message.newMethodReturn(message, "o", [path])); - } else if (message.member === "Activate") { - activateTitle = message.body[0]; - server!.send(Message.newMethodReturn(message)); - } else if (message.member === "Animate") { - animateFrame = message.body; - server!.send(Message.newMethodReturn(message)); - } else if (message.member === "Capture" || message.member === "CaptureWithFeedback") { - const feedback = message.member === "CaptureWithFeedback"; - if (feedback) feedbackArgs = message.body; - clientName = message.sender; - server!.send( - Message.newMethodReturn(message, feedback ? "aysb" : "ays", [ - png, - JSON.stringify({ - title: "Editor", - appName: "Editor", - appIdentifier: "editor.desktop", - processId: 42, - bounds: { x: 0, y: 0, width: 800, height: 600 }, - }), - ...(feedback ? [true] : []), - ]), - ); - } else return false; - return true; - }); - const connect = () => { - const client = new LinuxCaptureConnection(sessionBus({ busAddress: String(address) })); - clients.push(client); - return client; - }; - const setup = new GnomeCaptureSetup( - { - bundle: NodePath.resolve(import.meta.dirname, "../../gnome-extension"), - dataHome: directory, - }, - sessionBus({ busAddress: String(address) }), - ); - try { - expect((await setup.state()).status).toBe("disabled"); - await setup.perform("enable-extension"); - expect((await setup.state()).status).toBe("enabled"); - await setup.perform("disable-extension"); - expect((await setup.state()).status).toBe("disabled"); - } finally { - setup.close(); - } - const portal = connect(); - expect(await portal.backend("com.t3tools.T3Code")).toBe("screenshot-portal"); - expect(await portal.capturePortal()).toEqual({ png }); - expect(target).toBe(8); - portalVersion = 2; - const extension = connect(); - expect(await extension.backend("com.t3tools.T3Code")).toBe("gnome-extension"); - expect(await extension.captureExtension("com.t3tools.T3Code")).toMatchObject({ - png, - window: { processId: 42 }, - }); - const owner = await server.call( - new Message({ - destination: "org.freedesktop.DBus", - path: "/org/freedesktop/DBus", - interface: "org.freedesktop.DBus", - member: "GetNameOwner", - signature: "s", - body: ["com.t3tools.T3Code.SnapShot"], - }), - ); - expect(owner?.body[0]).toBe(clientName); - extension.close(); - extensionVersion = 2; - const updated = connect(); - expect(await updated.backend("com.t3tools.T3Code")).toBe("gnome-extension"); - const snapshot = await updated.captureExtension("com.t3tools.T3Code", { - flash: true, - animate: true, - }); - expect(snapshot.feedback?.animationStarted).toBe(true); - await snapshot.feedback!.activate("T3 Code"); - await snapshot.feedback!.animateTo({ x: 0.1, y: 0.8, width: 0.2, height: 0.1 }); - await snapshot.feedback!.complete(); - expect(feedbackArgs).toEqual([true, true]); - expect(activateTitle).toBe("T3 Code"); - expect(animateFrame).toEqual([0.1, 0.8, 0.2, 0.1]); - vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE"); - const kde = connect(); - expect(await kde.backend("com.t3tools.T3Code")).toBe("kde"); - expect(kde.feedbackAvailable).toBe(false); - kde.close(); - const triggered = vi.fn(); - const failed = vi.fn(); - const niriBus = sessionBus({ busAddress: String(address) }); - stopNiriShortcut = await startNiriCaptureShortcut( - "com.t3tools.T3Code.NiriTest", - triggered, - failed, - niriBus, - ); - const command = [ - "call", - "--session", - "--dest", - "com.t3tools.T3Code.NiriTest.SnapShot", - "--object-path", - "/com/t3tools/SnapShot", - "--method", - "com.t3tools.SnapShot.Capture", - ]; - await new Promise((resolve, reject) => { - NodeChildProcess.execFile( - "gdbus", - command, - { - env: { ...process.env, DBUS_SESSION_BUS_ADDRESS: String(address) }, - timeout: 3_000, - }, - (error) => (error ? reject(error) : resolve()), - ); - }); - expect(triggered).toHaveBeenCalledOnce(); - expect(failed).not.toHaveBeenCalled(); - const invalid = server.call( - new Message({ - destination: "com.t3tools.T3Code.NiriTest.SnapShot", - path: "/com/t3tools/SnapShot", - interface: "com.t3tools.SnapShot", - member: "Capture", - signature: "s", - body: ["not allowed"], - }), - ); - await expect(invalid).rejects.toThrow("Capture takes no arguments"); - expect(triggered).toHaveBeenCalledOnce(); - await expect( - startNiriCaptureShortcut( - "com.t3tools.T3Code.NiriTest", - triggered, - failed, - sessionBus({ busAddress: String(address) }), - ), - ).rejects.toThrow("already owns"); - stopNiriShortcut(); - const restarted = await startNiriCaptureShortcut( - "com.t3tools.T3Code.NiriTest", - triggered, - failed, - sessionBus({ busAddress: String(address) }), - ); - restarted(); - } finally { - vi.unstubAllEnvs(); - stopNiriShortcut?.(); - for (const client of clients) client.close(); - server?.disconnect(); - if (daemon && daemon.exitCode === null) { - const exited = NodeEvents.EventEmitter.once(daemon, "exit"); - daemon.kill("SIGTERM"); - await exited; - } - await NodeFSP.rm(directory, { recursive: true }); - } -}); diff --git a/apps/desktop/src/snapShot/LinuxSnapShot.test.ts b/apps/desktop/src/snapShot/LinuxSnapShot.test.ts index 0ea9c08f935c..49aa4dd72177 100644 --- a/apps/desktop/src/snapShot/LinuxSnapShot.test.ts +++ b/apps/desktop/src/snapShot/LinuxSnapShot.test.ts @@ -33,12 +33,7 @@ vi.mock("electron", () => ({ }, })); -import { - captureLinuxWindow, - getLinuxCaptureSupport, - readPortalPng, - resizeLinuxCapture, -} from "./LinuxSnapShot.ts"; +import { captureLinuxWindow, getLinuxCaptureSupport, readPortalPng } from "./LinuxSnapShot.ts"; const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0]); const appId = "com.t3tools.T3Code"; @@ -207,19 +202,6 @@ it("uses the standard path when KWin's native capability is missing, but not whe bus.kdeError = new DBusError("org.freedesktop.DBus.Error.AccessDenied", "Denied"); await expect(getLinuxCaptureSupport(appId)).rejects.toThrow("Denied"); }); - -it("does not fall through to the picker when KDE needs helper setup", async () => { - vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE"); - await expect(captureLinuxWindow(appId)).rejects.toThrow("KDE capture setup"); - expect(bus.calls.some((call) => call.member === "Screenshot")).toBe(false); - expect(bus.disconnect).toHaveBeenCalledOnce(); -}); -it("selects Hyprland without probing a different desktop or falling back on missing setup", async () => { - vi.stubEnv("XDG_CURRENT_DESKTOP", "Hyprland"); - expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("hyprland"); - await expect(captureLinuxWindow(appId)).rejects.toThrow("Hyprland capture setup"); - expect(bus.calls).toEqual([]); -}); afterEach(async () => { vi.useRealTimers(); vi.unstubAllEnvs(); @@ -246,18 +228,6 @@ it.each([ }, ); -it.each([1, 2])( - "reports feedback separately from capture support for extension v%s", - async (version) => { - bus.portalVersion = 2; - bus.extensionVersion = version; - expect(await getLinuxCaptureSupport(appId)).toEqual({ - linuxBackend: "gnome-extension", - linuxFeedbackAvailable: version === 2, - }); - }, -); - it("retains the authenticated connection until the compositor flight has landed", async () => { bus.portalVersion = 2; bus.extensionVersion = 2; @@ -294,19 +264,6 @@ it("keeps capture working with v1 and does not request unavailable effects", asy expect(bus.disconnect).toHaveBeenCalledOnce(); }); -it("can activate without animation, and expires a renderer-abandoned capture", async () => { - vi.useFakeTimers(); - bus.portalVersion = 2; - bus.extensionVersion = 2; - const snapshot = await captureLinuxWindow(appId, { flash: false, animate: false }); - expect(snapshot!.feedback!.animationStarted).toBe(false); - bus.activationError = new Error("No window"); - await expect(snapshot!.feedback!.activate("T3 Code")).rejects.toThrow("No window"); - expect(snapshot!.png).toEqual(png); - await vi.advanceTimersByTimeAsync(15_000); - expect(bus.disconnect).toHaveBeenCalledOnce(); -}); - it.each([false, true])( "accepts an early Response with a changed request path: %s", async (differentHandle) => { @@ -338,52 +295,6 @@ it.each([1, 2])( }, ); -it("does not downgrade a failed screenshot request", async () => { - bus.screenshotError = new DBusError("org.freedesktop.DBus.Error.AccessDenied", "Denied"); - await expect(captureLinuxWindow(appId)).rejects.toThrow("Denied"); - expect(bus.send).toHaveBeenCalledWith(expect.objectContaining({ member: "Close" })); - expect(bus.disconnect).toHaveBeenCalledOnce(); -}); - -it.each([false, true])( - "times out and closes requests, ignoring forged responses: %s", - async (wrongSender) => { - vi.useFakeTimers(); - bus.sendResponse = wrongSender; - bus.wrongSender = wrongSender; - const capture = expect(captureLinuxWindow(appId)).rejects.toThrow("timed out"); - await vi.advanceTimersByTimeAsync(120_001); - await capture; - expect(bus.send).toHaveBeenCalledWith(expect.objectContaining({ member: "Close" })); - expect(bus.listenerCount("message")).toBe(0); - expect(bus.disconnect).toHaveBeenCalledOnce(); - }, -); - -it("uses the GNOME extension and preserves its window identity", async () => { - bus.portalVersion = 2; - bus.extensionVersion = 1; - expect(await captureLinuxWindow(appId)).toEqual({ png, window: metadata }); - expect(bus.requestName).toHaveBeenCalledWith(`${appId}.SnapShot`, expect.any(Number)); - expect(bus.calls.some((call) => call.member === "Screenshot")).toBe(false); -}); - -it("fails instead of opening a picker after an extension denial", async () => { - bus.portalVersion = 2; - bus.extensionVersion = 1; - bus.extensionError = new Error("Locked session"); - await expect(captureLinuxWindow(appId)).rejects.toThrow("Locked session"); - expect(bus.disconnect).toHaveBeenCalledOnce(); -}); - -it("allows the picker only when both automatic backends are absent", async () => { - bus.portalVersion = 2; - expect(await captureLinuxWindow(appId)).toBeUndefined(); - expect(bus.calls.some((call) => call.member === "Screenshot" || call.member === "Capture")).toBe( - false, - ); -}); - it("tolerates an older portal without Registry, but not registration denial", async () => { bus.registryError = new DBusError("org.freedesktop.DBus.Error.UnknownMethod", "Old portal"); expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("screenshot-portal"); @@ -405,10 +316,3 @@ it("reads only local PNG files without removing the portal file", async () => { await NodeFSP.writeFile(NodePath.join(directory, "image.png"), "not a PNG"); await expect(readPortalPng(bus.uri)).rejects.toThrow("Invalid"); }); - -it("bounds large images without distorting their aspect ratio", () => { - imageSize.width = 4000; - imageSize.height = 2000; - resizeLinuxCapture(png); - expect(resize).toHaveBeenLastCalledWith({ width: 2560, height: 1280, quality: "best" }); -}); diff --git a/apps/desktop/src/snapShot/LinuxSnapShot.ts b/apps/desktop/src/snapShot/LinuxSnapShot.ts index 8603232e7e9f..3d05c5eecceb 100644 --- a/apps/desktop/src/snapShot/LinuxSnapShot.ts +++ b/apps/desktop/src/snapShot/LinuxSnapShot.ts @@ -17,7 +17,7 @@ import { import * as Schema from "effect/Schema"; import { isKdeCaptureSession, type KdeCapturePaths } from "./KdeSnapShot.ts"; import { isGnomeCaptureSession, readPortalPng, resizeLinuxCapture } from "./linuxCaptureSession.ts"; -export { readPortalPng, resizeLinuxCapture } from "./linuxCaptureSession.ts"; +export { readPortalPng } from "./linuxCaptureSession.ts"; import { isHyprlandCaptureSession, type HyprlandCapturePaths } from "./HyprlandSnapShot.ts"; const PORTAL = "org.freedesktop.portal.Desktop"; @@ -98,7 +98,7 @@ function unavailable(error: unknown): boolean { } /** One connection per operation: portal registration and request ownership stay together. */ -export class LinuxCaptureConnection { +class LinuxCaptureConnection { private readonly bus: MessageBus; private readonly disconnected: Promise; private uniqueName = ""; diff --git a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.test.ts b/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.test.ts deleted file mode 100644 index 81889f9f07eb..000000000000 --- a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -const { spawnedPollers } = vi.hoisted(() => ({ - spawnedPollers: [] as Array<{ - command: string; - args: ReadonlyArray; - kill: ReturnType; - emitStderr: (text: string) => void; - emitExit: (code: number) => void; - }>, -})); - -vi.mock("node:child_process", () => ({ - spawn: (command: string, args: ReadonlyArray) => { - const stderrListeners: Array<(chunk: Buffer) => void> = []; - const onceListeners = new Map void>>(); - const record = { - command, - args, - kill: vi.fn(() => true), - emitStderr: (text: string) => { - for (const listener of stderrListeners) listener(Buffer.from(text)); - }, - emitExit: (code: number) => { - for (const listener of onceListeners.get("exit") ?? []) listener(code); - }, - }; - spawnedPollers.push(record); - const child = { - stderr: { - on: (_event: "data", listener: (chunk: Buffer) => void) => { - stderrListeners.push(listener); - return child; - }, - }, - once: (event: string, listener: (value?: unknown) => void) => { - onceListeners.set(event, [...(onceListeners.get(event) ?? []), listener]); - return child; - }, - kill: record.kill, - }; - return child; - }, -})); - -import { startMacModifierPairShortcutProcess } from "./MacModifierPairShortcutProcess.ts"; - -describe("macOS modifier pair poller", () => { - it("resolves on ready, triggers on lines, and kills on stop", async () => { - spawnedPollers.length = 0; - const onTrigger = vi.fn(); - const onFailure = vi.fn(); - const started = startMacModifierPairShortcutProcess("meta", onTrigger, onFailure); - const poller = spawnedPollers[0]!; - expect(poller.command).toBe("/usr/bin/osascript"); - expect(poller.args.slice(-2)).toEqual(["8", "16"]); - - poller.emitStderr("ready\ntrig"); - const stop = await started; - poller.emitStderr("ger\ntrigger\n"); - expect(onTrigger).toHaveBeenCalledTimes(2); - - stop(); - expect(poller.kill).toHaveBeenCalledOnce(); - poller.emitStderr("trigger\n"); - expect(onTrigger).toHaveBeenCalledTimes(2); - poller.emitExit(0); - expect(onFailure).not.toHaveBeenCalled(); - }); - - it("reports an unexpected exit after startup", async () => { - spawnedPollers.length = 0; - const onFailure = vi.fn(); - const started = startMacModifierPairShortcutProcess("shift", () => undefined, onFailure); - const poller = spawnedPollers[0]!; - poller.emitStderr("ready\n"); - await started; - - poller.emitExit(1); - expect(onFailure).toHaveBeenCalledOnce(); - }); - - it("rejects when the poller dies before it is ready", async () => { - spawnedPollers.length = 0; - const started = startMacModifierPairShortcutProcess( - "control", - () => undefined, - () => undefined, - ); - spawnedPollers[0]!.emitExit(1); - await expect(started).rejects.toThrow(/exited with code 1/); - }); -}); diff --git a/apps/desktop/src/snapShot/MacSnapShot.test.ts b/apps/desktop/src/snapShot/MacSnapShot.test.ts deleted file mode 100644 index 43ff082204ae..000000000000 --- a/apps/desktop/src/snapShot/MacSnapShot.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { assert, beforeEach, expect, it, vi } from "vite-plus/test"; - -const { createFromBufferMock, execFileMock, readFileMock, writeFileMock } = vi.hoisted(() => ({ - createFromBufferMock: vi.fn(), - execFileMock: vi.fn(), - readFileMock: vi.fn(), - writeFileMock: vi.fn(), -})); - -vi.mock("node:child_process", () => ({ execFile: execFileMock })); -vi.mock("node:fs/promises", () => ({ readFile: readFileMock, writeFile: writeFileMock })); -vi.mock("electron", () => ({ nativeImage: { createFromBuffer: createFromBufferMock } })); - -import { captureMacWindowSnapshot, macSnapShotArguments } from "./MacSnapShot.ts"; - -beforeEach(() => { - execFileMock - .mockReset() - .mockImplementation( - ( - _path: string, - _args: ReadonlyArray, - _options: { readonly timeout: number }, - callback: (error: Error | null) => void, - ) => callback(null), - ); - readFileMock.mockReset(); - writeFileMock.mockReset(); - createFromBufferMock.mockReset().mockReturnValue({ - getSize: () => ({ width: 100, height: 80 }), - isEmpty: () => false, - }); -}); - -it("captures one macOS window directly as a silent shadowless PNG", async () => { - const png = Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - Buffer.from("image"), - ]); - readFileMock.mockResolvedValue(png); - const active = { - id: 42, - title: "Editor", - owner: { name: "Code" }, - } as Parameters[0]; - - const capture = await captureMacWindowSnapshot(active, "/tmp/window.png", { - width: 100, - height: 80, - }); - - assert.deepEqual(execFileMock.mock.calls[0]?.slice(0, 3), [ - "/usr/sbin/screencapture", - macSnapShotArguments(42, "/tmp/window.png"), - { timeout: 15_000 }, - ]); - assert.deepEqual(readFileMock.mock.calls, [["/tmp/window.png"]]); - assert.lengthOf(writeFileMock.mock.calls, 0); - assert.strictEqual(capture.png, png); - assert.deepEqual(capture.source, { name: "Editor" }); -}); - -it("bounds Retina captures without changing their aspect ratio", async () => { - const capturedPng = Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - Buffer.from("retina"), - ]); - const resizedPng = Buffer.from("bounded png"); - const resizeMock = vi.fn().mockReturnValue({ toPNG: () => resizedPng }); - readFileMock.mockResolvedValue(capturedPng); - createFromBufferMock.mockReturnValue({ - getSize: () => ({ width: 2_000, height: 1_200 }), - isEmpty: () => false, - resize: resizeMock, - }); - const active = { - id: 42, - title: "Editor", - owner: { name: "Code" }, - } as Parameters[0]; - - const capture = await captureMacWindowSnapshot(active, "/tmp/window.png", { - width: 1_000, - height: 1_000, - }); - - assert.deepEqual(resizeMock.mock.calls, [[{ width: 1_000, height: 600, quality: "best" }]]); - assert.deepEqual(writeFileMock.mock.calls, [["/tmp/window.png", resizedPng]]); - assert.strictEqual(capture.png, resizedPng); -}); - -it("rejects a successful command that did not produce a PNG", async () => { - readFileMock.mockResolvedValue(Buffer.from("not a png")); - const active = { - id: 42, - title: "", - owner: { name: "Code" }, - } as Parameters[0]; - - await expect( - captureMacWindowSnapshot(active, "/tmp/window.png", { width: 100, height: 80 }), - ).rejects.toThrow("macOS returned an invalid snapshot."); -}); diff --git a/apps/desktop/src/snapShot/MacSnapShot.ts b/apps/desktop/src/snapShot/MacSnapShot.ts index f56c35cb56e0..e987a8fe202e 100644 --- a/apps/desktop/src/snapShot/MacSnapShot.ts +++ b/apps/desktop/src/snapShot/MacSnapShot.ts @@ -15,7 +15,7 @@ export type MacSnapShotSource = { readonly name: string; }; -export function macSnapShotArguments(windowId: number, outputPath: string): string[] { +function macSnapShotArguments(windowId: number, outputPath: string): string[] { return ["-l", String(windowId), "-o", "-x", "-t", "png", outputPath]; } diff --git a/apps/desktop/src/snapShot/NativeCaptureFeedback.test.ts b/apps/desktop/src/snapShot/NativeCaptureFeedback.test.ts index c0ca5dc87771..ee017fac4086 100644 --- a/apps/desktop/src/snapShot/NativeCaptureFeedback.test.ts +++ b/apps/desktop/src/snapShot/NativeCaptureFeedback.test.ts @@ -69,36 +69,6 @@ it("waits for compositor readiness and landing, then waits for cleanup on acknow expect(vi.getTimerCount()).toBe(0); }); -it("doesn't claim a flight when the compositor disables motion; flash still works", async () => { - const starting = startNativeCaptureFeedback("/helper", "/private", options); - event({ event: "ready", animate: false }); - const feedback = (await starting)!; - expect(feedback.animationStarted).toBe(false); - await feedback.animateTo("Draft", { x: 0, y: 0, width: 1, height: 1 }); - expect(commands).toEqual([]); - const complete = feedback.complete(); - child.emit("close"); - await complete; -}); - -it("falls back on startup failure, malformed replies, and timeout without leaving timers", async () => { - const starting = startNativeCaptureFeedback("/helper", "/private", options); - child.stdout.write("not JSON\n"); - expect(commands).toEqual(['{"command":"close"}\n']); - await vi.advanceTimersByTimeAsync(1000); - expect(child.kill).toHaveBeenCalledOnce(); - child.emit("close"); - expect(await starting).toBeUndefined(); - expect(vi.getTimerCount()).toBe(0); -}); - -it("bounds a missing ready receipt and cancels an in-flight animation on compositor failure", async () => { - const timed = startNativeCaptureFeedback("/helper", "/private", options); - await vi.advanceTimersByTimeAsync(2000); - child.emit("close"); - expect(await timed).toBeUndefined(); -}); - it("releases waiters if the overlay closes during flight", async () => { const starting = startNativeCaptureFeedback("/helper", "/private", options); event({ event: "ready", animate: true }); diff --git a/apps/desktop/src/snapShot/NiriSnapShot.test.ts b/apps/desktop/src/snapShot/NiriSnapShot.test.ts index 313d335d3886..6ff7db8e8cc1 100644 --- a/apps/desktop/src/snapShot/NiriSnapShot.test.ts +++ b/apps/desktop/src/snapShot/NiriSnapShot.test.ts @@ -227,16 +227,6 @@ it("fails promptly when Niri disconnects", async () => { await expect(checkNiriCaptureSupport(socketPath)).rejects.toThrow("disconnected"); }); -it("bounds requests even when the compositor never answers", async () => { - vi.useFakeTimers(); - const received = Promise.withResolvers(); - handler = async () => received.resolve(); - const result = expect(checkNiriCaptureSupport(socketPath)).rejects.toThrow("timed out"); - await received.promise; - await vi.advanceTimersByTimeAsync(5_000); - await result; -}); - it("does not use stale, relative, or sandboxed Niri sockets", () => { expect(niriSocketPath()).toBe(socketPath); expect(niriSocketPath({ XDG_CURRENT_DESKTOP: "GNOME", NIRI_SOCKET: socketPath })).toBeUndefined(); diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts deleted file mode 100644 index acd9a29b3220..000000000000 --- a/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Private D-Bus integration fixture, never the user's session bus. -import * as NodeChildProcess from "node:child_process"; -import * as NodeEvents from "node:events"; -import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import * as NodeReadline from "node:readline"; -import { Message, NameFlag, Variant, sessionBus, type MessageBus } from "dbus-next"; -import { expect, it, vi } from "vite-plus/test"; -import { PortalCaptureShortcut } from "./PortalCaptureShortcut.ts"; - -it.runIf(NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0)( - "registers, rebinds saved keys, receives activations, and replaces sessions over real D-Bus", - async () => { - const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-shortcut-dbus-")); - let daemon: NodeChildProcess.ChildProcess | undefined; - let server: MessageBus | undefined; - const clients: PortalCaptureShortcut[] = []; - try { - vi.stubEnv("FLATPAK_ID", ""); - vi.stubEnv("SNAP", ""); - daemon = NodeChildProcess.spawn( - "dbus-daemon", - [ - "--session", - "--nofork", - "--nopidfile", - "--print-address", - `--address=unix:path=${NodePath.join(dir, "bus")}`, - ], - { stdio: ["ignore", "pipe", "pipe"] }, - ); - const lines = NodeReadline.createInterface({ input: daemon.stdout! }); - const [address] = await Promise.race([ - NodeEvents.EventEmitter.once(lines, "line"), - NodeEvents.EventEmitter.once(daemon, "exit").then(() => { - throw new Error("Private bus failed to start"); - }), - ]); - lines.close(); - const connect = () => sessionBus({ busAddress: String(address) }); - server = connect(); - server.on("error", () => undefined); - await server.requestName("org.freedesktop.portal.Desktop", NameFlag.DO_NOT_QUEUE); - const root = "/org/freedesktop/portal/desktop"; - const iface = "org.freedesktop.portal.GlobalShortcuts"; - const sessions = new Map(); - const bindings = new Map(); - const identities = new Set(); - const closed = new Set(); - const closeWaiters = new Map void>(); - let bindCount = 0; - server.addMethodHandler((message: Message) => { - if (message.member === "Register") { - expect(message.body[0]).toBe("com.t3tools.T3Code"); - identities.add(message.sender); - server!.send(Message.newMethodReturn(message)); - } else if (message.member === "Get") { - server!.send(Message.newMethodReturn(message, "v", [new Variant("u", 2)])); - } else if (message.member === "Close") { - closed.add(message.path); - closeWaiters.get(message.path)?.(); - } else if (["CreateSession", "BindShortcuts"].includes(message.member)) { - expect(identities.has(message.sender)).toBe(true); - const options = message.body.at(-1) as Record>; - const sender = message.sender.slice(1).replaceAll(".", "_"); - const handle = `${root}/request/${sender}/${options.handle_token!.value}`; - let results: Record>; - if (message.member === "CreateSession") { - const session = `${root}/session/${sender}/${options.session_handle_token!.value}`; - sessions.set(message.sender, session); - results = { session_handle: new Variant("s", session) }; - } else { - bindCount++; - const shortcuts = message.body[1] as Array< - [string, { preferred_trigger: Variant }] - >; - expect(shortcuts).toHaveLength(1); - const [id, properties] = shortcuts[0]!; - expect(properties.preferred_trigger.signature).toBe("s"); - bindings.set(message.sender, id); - const bound = [ - [ - id, - { - description: new Variant("s", "Capture a window"), - trigger_description: new Variant("s", properties.preferred_trigger.value), - }, - ], - ]; - results = { shortcuts: new Variant("a(sa{sv})", bound) }; - } - const response = Message.newSignal( - handle, - "org.freedesktop.portal.Request", - "Response", - "ua{sv}", - [0, results], - ); - response.destination = message.sender; - server!.send(response); - server!.send(Message.newMethodReturn(message, "o", [handle])); - } else return false; - return true; - }); - - const shortcut = { - key: "2", - modKey: true, - ctrlKey: false, - shiftKey: true, - altKey: false, - metaKey: false, - }; - const start = (key = "2") => { - const received = Promise.withResolvers(); - const capture = vi.fn(() => received.resolve()); - const client = new PortalCaptureShortcut( - "com.t3tools.T3Code", - { ...shortcut, key }, - capture, - () => {}, - connect(), - ); - clients.push(client); - return { client, capture, received: received.promise }; - }; - const activate = (sender: string) => { - const signal = Message.newSignal(root, iface, "Activated", "osta{sv}", [ - sessions.get(sender), - bindings.get(sender), - 123, - {}, - ]); - signal.destination = sender; - server!.send(signal); - }; - const first = start(); - await first.client.ready; - expect(first.client.state.shortcutRegistered).toBe(true); - const firstSender = [...bindings.keys()][0]!; - activate(firstSender); - await first.received; - expect(first.capture).toHaveBeenCalledOnce(); - const sessionClosed = new Promise((resolve) => - closeWaiters.set(sessions.get(firstSender)!, resolve), - ); - first.client.close(); - await sessionClosed; - expect(closed.has(sessions.get(firstSender)!)).toBe(true); - - const restoredClient = start(); - await restoredClient.client.ready; - expect(restoredClient.client.state.shortcutRegistered).toBe(true); - expect(bindCount).toBe(2); - expect([...bindings.values()][1]).toBe(bindings.get(firstSender)); - const next = start("8"); - await next.client.ready; - expect(next.client.state.shortcutLabel).toBe("CTRL+SHIFT+8"); - expect(bindCount).toBe(3); - const lastSender = [...bindings.keys()].at(-1)!; - expect(bindings.get(lastSender)).not.toBe(bindings.get(firstSender)); - activate(lastSender); - await next.received; - expect(next.capture).toHaveBeenCalledOnce(); - } finally { - clients.forEach((client) => client.close()); - server?.disconnect(); - if (daemon && daemon.exitCode === null) { - const exited = NodeEvents.EventEmitter.once(daemon, "exit"); - daemon.kill(); - await exited; - } - vi.unstubAllEnvs(); - await NodeFSP.rm(dir, { recursive: true, force: true }); - } - }, -); diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts index eb20a4a041ee..35c007e35dc0 100644 --- a/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts +++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts @@ -158,24 +158,6 @@ it("accepts Hyprland's action-only binding without claiming the keys are reserve expect(client.state.shortcutActionRegistered).toBe(false); }); -it("keeps the Hyprland action ID stable regardless of a saved key chord", async () => { - const first = start(new FakeBus(), chord, true); - const second = start(new FakeBus(), { ...chord, key: "7" }, true); - await Promise.all([first.client.ready, second.client.ready]); - expect(first.bus.boundId).toBe(second.bus.boundId); -}); - -it("does not accept an empty trigger as approval on other desktops", async () => { - const bus = new FakeBus(); - bus.actualLabel = ""; - const { client, capture } = start(bus); - await client.ready; - expect(client.state.shortcutRegistered).toBe(false); - expect(client.state.shortcutActionRegistered).not.toBe(true); - bus.activate(); - expect(capture).not.toHaveBeenCalled(); -}); - it.each([ ["2", "CTRL+SHIFT+2"], ["f12", "CTRL+SHIFT+F12"], @@ -309,23 +291,6 @@ it("treats a remembered empty binding as unassigned, then observes desktop-side expect(capture).toHaveBeenCalledOnce(); }); -it("guides users to manual desktop settings when the portal cannot open them", async () => { - const bus = new FakeBus(); - bus.version = 1; - bus.bindStatus = 1; - const { client } = start(bus); - await client.ready; - expect(client.hasSession).toBe(true); - expect(client.state.shortcutCanRetry).toBe(false); - expect(client.state.shortcutMessage).toBe( - "Shortcut permission wasn't granted. Allow T3 Code in your desktop's shortcut settings.", - ); - await expect(client.configure()).rejects.toThrow("Open your desktop's shortcut settings"); - expect(bus.calls.some((message) => message.member === "ConfigureShortcuts")).toBe(false); - client.close(); - expect(client.hasSession).toBe(false); -}); - it("captures on older portals without offering an unsupported permission dialog", async () => { const bus = new FakeBus(); bus.version = 1; @@ -361,21 +326,6 @@ it("closes an outstanding consent request and ignores its late response", async expect(bus.disconnect).toHaveBeenCalledOnce(); }); -it("bounds unanswered consent and cleans up the pending request", async () => { - vi.useFakeTimers(); - const bus = new FakeBus(); - bus.autoBind = false; - const binding = NodeEvents.EventEmitter.once(bus, "bind"); - const { client } = start(bus); - await binding; - await vi.advanceTimersByTimeAsync(120_000); - await client.ready; - expect(client.state.shortcutPending).toBe(false); - expect(client.state.shortcutMessage).toContain("timed out"); - expect(bus.disconnect).toHaveBeenCalledOnce(); - expect(vi.getTimerCount()).toBe(0); -}); - it("handles a disappearing portal without keeping a false registered state", async () => { const { bus, client } = start(); await client.ready; diff --git a/apps/desktop/src/snapShot/RegionSnapShot.test.ts b/apps/desktop/src/snapShot/RegionSnapShot.test.ts index 0d6381bb3f28..24b233b465d5 100644 --- a/apps/desktop/src/snapShot/RegionSnapShot.test.ts +++ b/apps/desktop/src/snapShot/RegionSnapShot.test.ts @@ -1,7 +1,6 @@ import type * as Electron from "electron"; import { assert, beforeEach, expect, it, vi } from "vite-plus/test"; -import type { ActiveWindow } from "./ActiveWindow.ts"; import type { RegionSnapShotChild, RegionSnapShotProcess } from "./RegionSnapShot.ts"; const { createFromBufferMock, resizeMock } = vi.hoisted(() => ({ @@ -13,59 +12,13 @@ vi.mock("electron", () => ({ nativeImage: { createFromBuffer: createFromBufferMock }, })); -import { - captureRegionWindowSnapshot, - makeRegionSnapShotPool, - startRegionSnapShotProcess, -} from "./RegionSnapShot.ts"; +import { makeRegionSnapShotPool, startRegionSnapShotProcess } from "./RegionSnapShot.ts"; beforeEach(() => { vi.clearAllMocks(); createFromBufferMock.mockReturnValue({ resize: resizeMock }); }); -const active = { - title: "Editor", - owner: { name: "Editor", processId: 123 }, - bounds: { x: 10, y: 20, width: 800, height: 600 }, -} as ActiveWindow; - -it("returns a small capture's PNG untouched", async () => { - const png = Buffer.from([1, 2, 3]); - const capture = vi.fn(async () => ({ width: 400, height: 300, png })); - const region = { x: 5, y: 10, width: 400, height: 300 }; - - const result = await captureRegionWindowSnapshot({ capture }, active, region, region); - - assert.deepEqual(capture.mock.calls, [[region]]); - assert.strictEqual(result.png, png); - assert.deepEqual(result.source, { name: "Editor" }); - assert.lengthOf(createFromBufferMock.mock.calls, 0); -}); - -it.each([ - { width: 600, height: 400, expected: { width: 240, height: 160 } }, - { width: 400, height: 600, expected: { width: 107, height: 160 } }, - { width: 600, height: 1, expected: { width: 256, height: 1 } }, -])( - "bounds $width x $height physical pixels before encoding", - async ({ width, height, expected }) => { - const png = Buffer.from([1, 2, 3]); - const resizedPng = Buffer.from([4, 5, 6]); - const capture = vi.fn(async () => ({ width, height, png })); - resizeMock.mockReturnValue({ toPNG: () => resizedPng }); - - const result = await captureRegionWindowSnapshot({ capture }, active, active.bounds, { - width: 256, - height: 160, - }); - - assert.deepEqual(createFromBufferMock.mock.calls, [[png]]); - assert.deepEqual(resizeMock.mock.calls, [[{ ...expected, quality: "best" }]]); - assert.strictEqual(result.png, resizedPng); - }, -); - function fakeChild() { const messageListeners: Array<(message: unknown) => void> = []; const exitListeners: Array<() => void> = []; @@ -92,37 +45,6 @@ function fakeChild() { const region = { x: 0, y: 0, width: 2, height: 1 }; -it("resolves the decoded capture once the child is ready and replies", async () => { - const fake = fakeChild(); - const fork = vi.fn(() => fake.child); - const png = Buffer.from([9, 8, 7]); - const child = startRegionSnapShotProcess("worker.cjs", fork); - assert.deepEqual(fork.mock.calls, [["worker.cjs"]]); - assert.isTrue(child.alive); - - const capture = child.capture(region); - await Promise.resolve(); - assert.lengthOf(fake.send.mock.calls, 0); - fake.emit("ready"); - await vi.waitFor(() => assert.deepEqual(fake.send.mock.calls, [[{ region }]])); - fake.emit({ type: "result", width: 2, height: 1, png: png.toString("base64") }); - - assert.deepEqual(await capture, { width: 2, height: 1, png }); - assert.isFalse(child.alive); - assert.lengthOf(fake.kill.mock.calls, 1); -}); - -it("rejects with the child's error message", async () => { - const fake = fakeChild(); - const child = startRegionSnapShotProcess("worker.cjs", () => fake.child); - const capture = child.capture(region); - fake.emit("ready"); - fake.emit({ type: "error", message: "Screen capture failed" }); - - await expect(capture).rejects.toThrow("Screen capture failed"); - assert.isFalse(child.alive); -}); - it("rejects when the child exits before replying", async () => { const fake = fakeChild(); const child = startRegionSnapShotProcess("worker.cjs", () => fake.child); @@ -133,19 +55,6 @@ it("rejects when the child exits before replying", async () => { await expect(capture).rejects.toThrow(/helper exited/); }); -it("serves a single capture per child", async () => { - const fake = fakeChild(); - const child = startRegionSnapShotProcess("worker.cjs", () => fake.child); - const first = child.capture(region); - fake.emit("ready"); - await vi.waitFor(() => assert.lengthOf(fake.send.mock.calls, 1)); - fake.emit({ type: "result", width: 1, height: 1, png: Buffer.from([1]).toString("base64") }); - await first; - - await expect(child.capture(region)).rejects.toThrow(/already used/); - assert.lengthOf(fake.send.mock.calls, 1); -}); - it("cancels a pending capture on close and kills the child", async () => { const fake = fakeChild(); const child = startRegionSnapShotProcess("worker.cjs", () => fake.child); @@ -159,31 +68,6 @@ it("cancels a pending capture on close and kills the child", async () => { assert.lengthOf(fake.send.mock.calls, 0); }); -it("reports an unavailable helper when the child cannot be forked", async () => { - const child = startRegionSnapShotProcess("worker.cjs", () => { - throw new Error("fork failed"); - }); - - assert.isFalse(child.alive); - await expect(child.capture(region)).rejects.toThrow(/unavailable/); -}); - -it("times out a stalled capture", async () => { - vi.useFakeTimers(); - try { - const fake = fakeChild(); - const child = startRegionSnapShotProcess("worker.cjs", () => fake.child); - const failure = expect(child.capture(region)).rejects.toThrow(/timed out/); - fake.emit("ready"); - await vi.advanceTimersByTimeAsync(15_000); - - await failure; - assert.lengthOf(fake.kill.mock.calls, 1); - } finally { - vi.useRealTimers(); - } -}); - function fakeProcess() { const result = Promise.withResolvers<{ width: number; height: number; png: Buffer }>(); void result.promise.catch(() => undefined); @@ -221,41 +105,6 @@ function fakePool() { return { pool: makeRegionSnapShotPool("worker.cjs", start), processes, start }; } -it("warms one standby child and keeps it while alive", () => { - const { pool, start } = fakePool(); - - pool.warm(); - pool.warm(); - - assert.deepEqual(start.mock.calls, [["worker.cjs"]]); -}); - -it("captures with the standby child and warms a replacement afterwards", async () => { - const { pool, processes, start } = fakePool(); - pool.warm(); - const shot = { width: 1, height: 1, png: Buffer.from([1]) }; - - const capture = pool.capture(region); - assert.deepEqual(processes[0]!.capture.mock.calls, [[region]]); - assert.lengthOf(start.mock.calls, 1); - processes[0]!.resolve(shot); - - assert.deepEqual(await capture, shot); - assert.lengthOf(start.mock.calls, 2); - assert.lengthOf(processes[1]!.capture.mock.calls, 0); -}); - -it("starts a child on demand when no standby is warm", async () => { - const { pool, processes, start } = fakePool(); - const shot = { width: 1, height: 1, png: Buffer.from([1]) }; - - const capture = pool.capture(region); - assert.lengthOf(start.mock.calls, 1); - processes[0]!.resolve(shot); - - assert.deepEqual(await capture, shot); -}); - it("rejects a second capture while one is in flight", async () => { const { pool, processes } = fakePool(); const first = pool.capture(region); @@ -267,17 +116,6 @@ it("rejects a second capture while one is in flight", async () => { await first; }); -it("cools the standby child", () => { - const { pool, processes, start } = fakePool(); - pool.warm(); - - pool.cool(); - - assert.lengthOf(processes[0]!.close.mock.calls, 1); - pool.warm(); - assert.lengthOf(start.mock.calls, 2); -}); - it("closes the standby and the in-flight child", async () => { const { pool, processes } = fakePool(); pool.warm(); diff --git a/apps/desktop/src/snapShot/SnapShotAccessibility.ts b/apps/desktop/src/snapShot/SnapShotAccessibility.ts index b0a0fe7b959c..3361aafc3c78 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibility.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibility.ts @@ -20,7 +20,7 @@ export type AccessibleWindowIdentity = { readonly title: string; readonly bounds: Electron.Rectangle; readonly clientBounds?: Electron.Rectangle; - readonly owner: { readonly processId: number }; + readonly owner: { readonly processId: number; readonly bundleId?: string }; readonly accessibilityBoundsReliable?: boolean; }; @@ -134,6 +134,8 @@ async function readCapturedWindowAccessibility( sourceTitle, bounds: active.bounds, clientBounds: active.clientBounds, + platform, + bundleId: pidWindows.length > 0 ? active.owner.bundleId : undefined, }; const window = findAccessibleWindow( pidWindows.length > 0 || foreground !== undefined ? pidWindows : await windowsFromAppList(App), diff --git a/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.test.ts b/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.test.ts index 6edfa017584a..e4e80c6c4db6 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.test.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.test.ts @@ -1,6 +1,5 @@ import { assert, beforeEach, it } from "@effect/vitest"; import * as NodeEvents from "node:events"; -import * as NodeProcess from "node:process"; import { vi } from "vite-plus/test"; const forkMock = vi.hoisted(() => @@ -62,42 +61,6 @@ it("returns immediately when accessibility crashes before the screenshot is read assert.isUndefined(await read.result); }); -it("returns accessibility extracted by the helper", async () => { - const process = startSnapShotAccessibilityProcess("accessibility.cjs"); - const read = process.read(request); - const context = { accessibleText: "Meeting controls" }; - worker.emit("message", "ready"); - worker.emit("message", "started"); - worker.emit("message", { type: "result", context }); - - await read.started; - assert.deepEqual(await read.result, context); - assert.strictEqual(forkMock.mock.calls[0]?.[2]?.env?.ELECTRON_RUN_AS_NODE, "1"); - assert.strictEqual(forkMock.mock.calls[0]?.[2]?.execPath, NodeProcess.execPath); -}); - -it("hands a warm helper to the capture and prewarms its replacement", async () => { - const warmWorker = makeWorker(); - const replacementWorker = makeWorker(); - forkMock.mockReset().mockReturnValueOnce(warmWorker).mockReturnValueOnce(replacementWorker); - const pool = makeSnapShotAccessibilityProcessPool("accessibility.cjs"); - - pool.warm(); - warmWorker.emit("message", "ready"); - const read = pool.read(request); - - assert.lengthOf(forkMock.mock.calls, 2); - assert.deepEqual(warmWorker.send.mock.calls[0]?.[0], request); - assert.lengthOf(replacementWorker.send.mock.calls, 0); - - warmWorker.emit("message", "started"); - warmWorker.emit("message", { type: "result", context: { accessibleText: "Ready" } }); - assert.deepEqual(await read.result, { accessibleText: "Ready" }); - - pool.close(); - assert.lengthOf(replacementWorker.kill.mock.calls, 1); -}); - it("replaces a warm helper that exits before capture", async () => { const exitedWorker = makeWorker(); const captureWorker = makeWorker(); @@ -121,21 +84,3 @@ it("replaces a warm helper that exits before capture", async () => { assert.deepEqual(captureWorker.send.mock.calls[0]?.[0], request); pool.close(); }); - -it("allows cold startup to exceed the request-start budget", async () => { - vi.useFakeTimers(); - const process = startSnapShotAccessibilityProcess("accessibility.cjs"); - try { - const read = process.read(request); - await vi.advanceTimersByTimeAsync(1_500); - assert.lengthOf(worker.kill.mock.calls, 0); - worker.emit("message", "ready"); - assert.deepEqual(worker.send.mock.calls[0]?.[0], request); - worker.emit("message", "started"); - worker.emit("message", { type: "result", context: { accessibleText: "Ready" } }); - assert.deepEqual(await read.result, { accessibleText: "Ready" }); - } finally { - process.close(); - vi.useRealTimers(); - } -}); diff --git a/apps/desktop/src/snapShot/SnapShotTransition.ts b/apps/desktop/src/snapShot/SnapShotTransition.ts index 31eae474f87c..a8ca237009bb 100644 --- a/apps/desktop/src/snapShot/SnapShotTransition.ts +++ b/apps/desktop/src/snapShot/SnapShotTransition.ts @@ -26,7 +26,7 @@ export type SnapShotAnimationDestination = { readonly details?: SnapShotAnimationDetails | undefined; }; -export function snapShotAnimationDurationMs( +function snapShotAnimationDurationMs( source: Electron.Rectangle, target: Electron.Rectangle, ): number { @@ -64,7 +64,7 @@ type SnapShotTransitionOptions = { | undefined; }; -export function snapShotAnimationOverlayBounds( +function snapShotAnimationOverlayBounds( displays: ReadonlyArray>, ): Electron.Rectangle { const firstDisplay = displays[0]; diff --git a/apps/desktop/src/snapShot/WindowsCaptureFeedback.test.ts b/apps/desktop/src/snapShot/WindowsCaptureFeedback.test.ts index 4a319d7ac36a..aa56de090897 100644 --- a/apps/desktop/src/snapShot/WindowsCaptureFeedback.test.ts +++ b/apps/desktop/src/snapShot/WindowsCaptureFeedback.test.ts @@ -33,29 +33,3 @@ it("disables Chromium window animation only while showing the capture overlay", expect(flagsDuringShow).toEqual([["unrelated-switch", animationSwitch]]); expect([...switches]).toEqual(["unrelated-switch"]); }); - -it("preserves an animation switch supplied before capture", () => { - switches.add(animationSwitch); - const flagsDuringShow: string[][] = []; - - showWindowsCaptureOverlay(overlay(() => flagsDuringShow.push([...switches]))); - - expect(flagsDuringShow).toEqual([["unrelated-switch", animationSwitch]]); - expect([...switches]).toEqual(["unrelated-switch", animationSwitch]); -}); - -it.each([false, true])( - "restores the original switch state when showing throws (preexisting: %s)", - (preexisting) => { - if (preexisting) switches.add(animationSwitch); - const original = [...switches]; - const failure = new Error("Overlay was destroyed"); - const window = overlay(() => { - expect(switches.has(animationSwitch)).toBe(true); - throw failure; - }); - - expect(() => showWindowsCaptureOverlay(window)).toThrow(failure); - expect([...switches]).toEqual(original); - }, -); diff --git a/apps/desktop/src/snapShot/captureConfigEdit.test.ts b/apps/desktop/src/snapShot/captureConfigEdit.test.ts index 6d57d35ee8b6..9db407be961d 100644 --- a/apps/desktop/src/snapShot/captureConfigEdit.test.ts +++ b/apps/desktop/src/snapShot/captureConfigEdit.test.ts @@ -39,14 +39,6 @@ describe("Niri capture config edits", () => { `\uFEFFbinds {\r\n ${binding}\r\n}\r\n`, ); }); - it("preserves Unicode spacing around nodes and line continuations", () => { - const spaces = - "\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000"; - const before = `binds${spaces}\\${spaces}\n{\n}\n`; - expect(editCaptureConfig(before, "niri", app, "install").after).toBe( - before.replace("}\n", ` ${binding}\n}\n`), - ); - }); it("does not duplicate an existing binding, including a custom chord", () => { const before = `binds {\n ${binding.replace("Ctrl+Shift+2", "Alt+Ctrl+Y")}\n}\n`; const result = editCaptureConfig(before, "niri", app, "install"); @@ -68,11 +60,6 @@ describe("Niri capture config edits", () => { expect(niriConfigConflict(before, app, "Ctrl+Shift+2")).toBe(true); expect(() => editCaptureConfig(before, "niri", app, "install")).toThrow("already used"); }); - it("does not count a slash-dash-disabled key as occupied", () => { - expect(niriConfigConflict("binds { /- Ctrl+Shift+2 { quit; }; }", app, "Ctrl+Shift+2")).toBe( - false, - ); - }); it.each(["binds {", "binds {}\nbinds {}", "/* unfinished", 'spawn "unfinished'])( "fails safely for malformed structure: %s", (source) => { @@ -115,10 +102,6 @@ describe("Hyprland capture config edits", () => { editCaptureConfig("bind = SHIFT CTRL, 2, exec, kitty\n", "hyprland", app, "install"), ).toThrow("already used"); }); - it("leaves commented bindings alone", () => { - const before = "# bind = CTRL SHIFT, 2, global, com.t3tools.T3Code:capture-window\n"; - expect(editCaptureConfig(before, "hyprland", app, "remove").after).toBe(before); - }); it.each([ "return {}", '--[[\nhl.bind("CTRL + SHIFT + 2", hl.dsp.global("com.t3tools.T3Code:capture-window"))\n]]', diff --git a/apps/desktop/src/snapShot/snapShot.test.ts b/apps/desktop/src/snapShot/snapShot.test.ts index ea52e877b93f..26ae8fbf9ebe 100644 --- a/apps/desktop/src/snapShot/snapShot.test.ts +++ b/apps/desktop/src/snapShot/snapShot.test.ts @@ -1,58 +1,19 @@ import { it as effectIt } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { accessibleWindowElementTree, accessibleWindowText, - capturedImageBounds, compactAccessibilityTree, findAccessibleWindow, findCaptureSource, hideAndWaitForBlur, isWaylandSession, - snapShotShortcutRegistrationFailureMessage, - snapShotShortcutSystemConflict, - toElectronAccelerator, } from "./snapShot.ts"; -import { DesktopSnapShotError } from "./DesktopSnapShot.ts"; - -describe("window capture errors", () => { - it.each([ - ["unsupported", "SnapShots are not supported here."], - ["disabled", "Enable SnapShots in Settings first."], - ["no-window-selected", "No window was selected."], - ["window-unavailable", "The active window is not available for capture."], - ["capture", "Could not capture the active window."], - ] as const)("keeps %s failures user-facing", (operation, message) => { - expect(new DesktopSnapShotError({ operation }).message).toBe(message); - }); -}); describe("accessibleWindowText", () => { - it("keeps unique names and values in tree order", () => { - expect( - accessibleWindowText( - { - name: "Settings", - children: [ - { - name: "General", - children: [], - }, - { - name: "Name", - value: "Bilal", - children: [], - }, - ], - }, - 100, - ), - ).toBe("Settings\nGeneral\nName\nBilal"); - }); - it("caps large text without splitting a surrogate pair", () => { expect( accessibleWindowText( @@ -263,23 +224,6 @@ describe("accessibleWindowElementTree", () => { expect(tree?.root.children.length).toBeLessThan(10); expect(JSON.stringify(tree).length).toBeLessThanOrEqual(32_000); }); - - it.each(["name", "value", "description"] as const)( - "reports clipped %s text in partial trees", - async (field) => { - const tree = await accessibleWindowElementTree( - { - role: "window", - children: async () => [{ role: "text_area", [field]: "x".repeat(8_001) }], - }, - { x: 0, y: 0, width: 800, height: 600 }, - { width: 800, height: 600 }, - ); - - expect(tree?.truncated).toBe(true); - expect(tree?.root.children[0]?.[field]?.length).toBeLessThan(8_001); - }, - ); }); describe("compactAccessibilityTree", () => { @@ -304,33 +248,63 @@ describe("compactAccessibilityTree", () => { }); }); -describe("capturedImageBounds", () => { - it("clips locations to the attached image", () => { - expect( - capturedImageBounds( - { x: 50, y: 150, width: 200, height: 200 }, - { x: 100, y: 200, width: 800, height: 600 }, - { width: 1_600, height: 1_200 }, - ), - ).toEqual({ x: 0, y: 0, width: 300, height: 300 }); +describe("findAccessibleWindow", () => { + const captured = { + title: "Editor", + bounds: { x: 100, y: 200, width: 800, height: 600 }, + }; + + const chromeCapture = { + title: "Issues · pingdotgg/t3code", + bounds: captured.bounds, + platform: "darwin" as const, + bundleId: "com.google.Chrome", + }; + const chromeWindow = { + name: `${chromeCapture.title} - Google Chrome – Profile`, + bounds: chromeCapture.bounds, + active: true, + }; + + it("uses a browser suffix only when no exact title matches", () => { + const exact = { name: chromeCapture.title, bounds: chromeCapture.bounds }; + expect(findAccessibleWindow([chromeWindow, exact], chromeCapture)).toBe(exact); + expect(findAccessibleWindow([chromeWindow], chromeCapture)).toBe(chromeWindow); }); - it("returns null when an accessibility provider omits a location", () => { + it("rejects browser suffix matches with wrong bounds, page titles, or ambiguous windows", () => { expect( - capturedImageBounds( - null, - { x: 0, y: 0, width: 800, height: 600 }, - { width: 800, height: 600 }, + findAccessibleWindow( + [{ ...chromeWindow, bounds: { ...chromeWindow.bounds, x: 0 } }], + chromeCapture, ), - ).toBeNull(); + ).toBeUndefined(); + expect( + findAccessibleWindow([chromeWindow, { ...chromeWindow, active: false }], chromeCapture), + ).toBeUndefined(); + expect( + findAccessibleWindow([chromeWindow], { ...chromeCapture, title: "Issues" }), + ).toBeUndefined(); + expect( + findAccessibleWindow([chromeWindow], { + ...chromeCapture, + title: `${chromeCapture.title} - Google Chrome`, + }), + ).toBeUndefined(); }); -}); -describe("findAccessibleWindow", () => { - const captured = { - title: "Editor", - bounds: { x: 100, y: 200, width: 800, height: 600 }, - }; + it("keeps strict titles outside macOS Chrome and Chromium", () => { + for (const identity of [ + { platform: "linux" as const, bundleId: "com.google.Chrome" }, + { platform: "win32" as const, bundleId: "com.google.Chrome" }, + { platform: "darwin" as const, bundleId: "com.example.Editor" }, + { platform: "darwin" as const, bundleId: undefined }, + ]) { + expect( + findAccessibleWindow([chromeWindow], { ...chromeCapture, ...identity }), + ).toBeUndefined(); + } + }); it("matches one window by its captured bounds", () => { const windows = [ @@ -341,17 +315,6 @@ describe("findAccessibleWindow", () => { expect(findAccessibleWindow(windows, captured)).toBe(windows[1]); }); - it("uses the matched source title when macOS omits the active title", () => { - const windows = [{ name: "Editor", bounds: captured.bounds }]; - expect( - findAccessibleWindow(windows, { - ...captured, - title: "", - sourceTitle: "Editor", - }), - ).toBe(windows[0]); - }); - it("uses the captured source title when the active title changed", () => { const windows = [{ name: "Editor — saved", bounds: captured.bounds }]; @@ -382,15 +345,6 @@ describe("findAccessibleWindow", () => { ).toBeUndefined(); }); - it("does not use a title match when the bounds differ", () => { - expect( - findAccessibleWindow( - [{ name: "Editor", bounds: { x: 0, y: 0, width: 800, height: 600 } }], - captured, - ), - ).toBeUndefined(); - }); - it("matches a Wayland window whose accessibility provider omits its screen position", () => { const captured = { title: "hello world (Draft) - Text Editor", @@ -440,24 +394,6 @@ describe("findAccessibleWindow", () => { expect(findAccessibleWindow(windows, captured, "wayland")).toBe(windows[1]); }); - it.each([ - { name: "Private", bounds: { x: 0, y: 0, width: 800, height: 600 } }, - { name: "Editor", bounds: { x: 0, y: 0, width: 400, height: 600 } }, - { name: "Editor", bounds: { x: 0, y: 0, width: 800, height: 300 } }, - { name: "Editor", bounds: null }, - ])("rejects an unverified Wayland window: %j", (window) => { - expect(findAccessibleWindow([window], captured, "wayland")).toBeUndefined(); - }); - - it("rejects ambiguous Wayland windows even when one reports the captured screen position", () => { - const windows = [ - { name: "Editor", bounds: captured.bounds }, - { name: "Editor", bounds: { x: 0, y: 0, width: 800, height: 600 } }, - ]; - - expect(findAccessibleWindow(windows, captured, "wayland")).toBeUndefined(); - }); - it("does not match an untitled Wayland window by size alone", () => { const windows = [{ name: "", bounds: captured.bounds }]; @@ -473,17 +409,6 @@ describe("findAccessibleWindow", () => { expect(findAccessibleWindow(windows, captured, "wayland")).toBeUndefined(); }); - it("rejects ambiguous PID-scoped untitled windows even when bounds match", () => { - const windows = [ - { name: null, bounds: captured.bounds }, - { name: "", bounds: { ...captured.bounds, x: 0, y: 0 } }, - ]; - - expect( - findAccessibleWindow(windows, captured, "wayland", { allowUntitledUniqueBounds: true }), - ).toBeUndefined(); - }); - it("does not fall back to a differently titled PID-scoped window with matching bounds", () => { const windows = [{ name: "Preferences", bounds: captured.bounds }]; @@ -503,17 +428,6 @@ describe("findAccessibleWindow", () => { ).toBeUndefined(); }); - it.each(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])( - "ignores a leading Wayland title spinner frame %s", - (frame) => { - const windows = [{ name: `${frame} t3code`, bounds: captured.bounds }]; - - expect(findAccessibleWindow(windows, { ...captured, title: "⠋ t3code" }, "wayland")).toBe( - windows[0], - ); - }, - ); - it.each([ ["⠋ t3code", "t3code"], ["t3code", "⠙ t3code"], @@ -534,36 +448,6 @@ describe("findAccessibleWindow", () => { findAccessibleWindow([{ name, bounds: captured.bounds }], { ...captured, title }, "wayland"), ).toBeUndefined(); }); - - it("rejects matching spinners when the window sizes differ", () => { - expect( - findAccessibleWindow( - [{ name: "⠙ t3code", bounds: { ...captured.bounds, width: 400 } }], - { ...captured, title: "⠋ t3code" }, - "wayland", - ), - ).toBeUndefined(); - }); - - it("rejects ambiguous normalized titles even if one matches the captured spinner exactly", () => { - const windows = [ - { name: "⠋ t3code", bounds: captured.bounds }, - { name: "⠙ t3code", bounds: captured.bounds }, - ]; - - expect( - findAccessibleWindow(windows, { ...captured, title: "⠋ t3code" }, "wayland"), - ).toBeUndefined(); - }); - - it("keeps exact title matching outside Wayland", () => { - expect( - findAccessibleWindow([{ name: "⠙ t3code", bounds: captured.bounds }], { - ...captured, - title: "⠋ t3code", - }), - ).toBeUndefined(); - }); }); describe("hideAndWaitForBlur", () => { @@ -586,67 +470,6 @@ describe("hideAndWaitForBlur", () => { await hidden; expect(settled).toBe(true); }); - - it("rejects when the hidden window never blurs", async () => { - vi.useFakeTimers(); - try { - let rejected = false; - const hidden = hideAndWaitForBlur({ - hide: () => undefined, - once: () => undefined, - removeListener: () => undefined, - }).catch(() => { - rejected = true; - }); - - await vi.advanceTimersByTimeAsync(1_000); - expect(rejected).toBe(true); - await hidden; - } finally { - vi.useRealTimers(); - } - }); -}); - -describe("toElectronAccelerator", () => { - it("converts the default portable shortcut", () => { - expect( - toElectronAccelerator({ - key: "2", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }), - ).toBe("CommandOrControl+Shift+2"); - }); - - it("maps the portable meta key to Super", () => { - expect( - toElectronAccelerator({ - key: "k", - metaKey: true, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: false, - }), - ).toBe("Super+K"); - }); - - it("normalizes Electron key names", () => { - expect( - toElectronAccelerator({ - key: "ArrowUp", - metaKey: false, - ctrlKey: true, - shiftKey: false, - altKey: true, - modKey: false, - }), - ).toBe("Control+Alt+Up"); - }); }); describe("findCaptureSource", () => { @@ -690,15 +513,6 @@ describe("findCaptureSource", () => { }); describe("isWaylandSession", () => { - it.each([ - ["linux", { XDG_SESSION_TYPE: "wayland" }, true], - ["linux", { WAYLAND_DISPLAY: "wayland-0" }, true], - ["linux", { XDG_SESSION_TYPE: "x11" }, false], - ["darwin", { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0" }, false], - ] as const)("detects %s session %o as portal=%s", (platform, environment, expected) => { - expect(isWaylandSession(platform, environment)).toBe(expected); - }); - effectIt.effect( "falls back to a live runtime directory socket when session variables are stripped", () => @@ -743,74 +557,3 @@ describe("isWaylandSession", () => { }), ); }); - -describe("snapShotShortcutRegistrationFailureMessage", () => { - it("distinguishes a modifier listener failure from a reserved key chord", () => { - expect( - snapShotShortcutRegistrationFailureMessage({ kind: "both-shift-keys" }, "darwin"), - ).toMatch(/Shift \+ Shift is not available/); - expect( - snapShotShortcutRegistrationFailureMessage( - { kind: "modifier-pair", modifier: "meta" }, - "darwin", - ), - ).toMatch(/Command \+ Command is not available/); - expect( - snapShotShortcutRegistrationFailureMessage( - { kind: "modifier-pair", modifier: "meta" }, - "linux", - ), - ).toMatch(/Super \+ Super is not available/); - expect( - snapShotShortcutRegistrationFailureMessage( - { - key: "2", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }, - "darwin", - ), - ).toMatch(/already used/); - }); -}); - -describe("snapShotShortcutSystemConflict", () => { - it("blocks shortcuts that would break typing or common app actions", () => { - expect( - snapShotShortcutSystemConflict({ - key: "s", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: false, - }), - ).toMatch(/typing/); - expect( - snapShotShortcutSystemConflict({ - key: "c", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }), - ).toMatch(/Copy/); - }); - - it("allows a specific multi-modifier shortcut", () => { - expect( - snapShotShortcutSystemConflict({ - key: "2", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }), - ).toBeNull(); - }); -}); diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 0c85a0148425..958e017299e0 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -168,7 +168,7 @@ export function boundedSnapShotString( return candidate.slice(0, end).trimEnd(); } -export function capturedImageBounds( +function capturedImageBounds( bounds: WindowBounds | null | undefined, sourceBounds: WindowBounds, imageSize: CapturedImageSize, @@ -477,6 +477,8 @@ export function findAccessibleWindow< readonly sourceTitle?: string; readonly bounds: WindowBounds; readonly clientBounds?: WindowBounds | undefined; + readonly platform?: NodeJS.Platform; + readonly bundleId?: string | undefined; }, matchMode: "screen-bounds" | "wayland" = "screen-bounds", options: { readonly allowUntitledUniqueBounds?: boolean } = {}, @@ -515,6 +517,8 @@ export function findAccessibleWindow< const activeMatches = matches.filter((window) => safeProperty(() => window.active) === true); if (activeMatches.length === 1) return activeMatches[0]; if (matches.length > 1) return undefined; + const browserMatch = findBrowserTitleMatch(windows, titles, captured, matchMode, matchesBounds); + if (browserMatch) return browserMatch; } // GTK4/libadwaita often exposes the frame as an unnamed group. A PID-scoped // lookup can accept the one window whose bounds match; size-only guesses cannot. @@ -525,6 +529,37 @@ export function findAccessibleWindow< return boundsMatches.length === 1 ? boundsMatches[0] : undefined; } +// Chrome's macOS AX title can append the browser and profile to the captured page title. +function findBrowserTitleMatch( + windows: readonly T[], + titles: ReadonlySet, + captured: { readonly platform?: NodeJS.Platform; readonly bundleId?: string | undefined }, + matchMode: "screen-bounds" | "wayland", + matchesBounds: (window: T) => boolean, +): T | undefined { + if (captured.platform !== "darwin" || matchMode !== "screen-bounds") return undefined; + const browserName = /^com\.google\.Chrome(?:\.(?:beta|dev|canary))?$/.test( + captured.bundleId ?? "", + ) + ? "Google Chrome" + : captured.bundleId === "org.chromium.Chromium" + ? "Chromium" + : undefined; + if (!browserName) return undefined; + const browserMatches = windows.filter((window) => { + if (!matchesBounds(window)) return false; + const name = window.name?.trim() ?? ""; + return [...titles].some((title) => { + const prefix = `${title} - ${browserName}`; + return ( + name === prefix || + (name.startsWith(prefix) && /^ [–—-] \S.*$/u.test(name.slice(prefix.length))) + ); + }); + }); + return browserMatches.length === 1 ? browserMatches[0] : undefined; +} + const ELECTRON_KEY_NAMES: Readonly> = { " ": "Space", "+": "Plus", diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts deleted file mode 100644 index 1fe2b86aae7b..000000000000 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import * as NetService from "@t3tools/shared/Net"; -import { SshPasswordPromptError } from "@t3tools/ssh/errors"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; - -import * as DesktopSshEnvironment from "./DesktopSshEnvironment.ts"; -import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; - -function makeTempHomeDir() { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-env-test-" }); - }); -} - -describe("sshEnvironment", () => { - it("keeps prompt presentation diagnostics distinct from the legacy wrapper message", () => { - const cause = new DesktopSshPasswordPrompts.DesktopSshPromptPresentationError({ - requestId: "prompt-1", - destination: "devbox", - operation: "send-prompt-request", - cause: new Error("renderer send failed"), - }); - - assert.equal(cause.message, "Failed to present SSH password prompt for devbox."); - assert.equal( - DesktopSshEnvironment.toSshPasswordPromptError(cause).message, - "T3 Code window is not available for SSH authentication.", - ); - }); - - it("treats password prompt timeouts as cancellable authentication prompts", () => { - assert.equal( - DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation( - new SshPasswordPromptError({ - message: "SSH authentication timed out for devbox.", - cause: new DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError({ - requestId: "prompt-1", - destination: "devbox", - }), - }), - ), - true, - ); - }); - - it.effect("wires desktop host discovery through the ssh package runtime", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const homeDir = yield* makeTempHomeDir(); - const sshDir = path.join(homeDir, ".ssh"); - yield* fs.makeDirectory(path.join(sshDir, "config.d"), { recursive: true }); - yield* fs.writeFileString( - path.join(sshDir, "config"), - ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"), - ); - yield* fs.writeFileString( - path.join(sshDir, "config.d", "team.conf"), - [ - "Host staging", - " HostName staging.example.com", - "Host *", - " ServerAliveInterval 30", - "", - ].join("\n"), - ); - yield* fs.writeFileString( - path.join(sshDir, "known_hosts"), - [ - "known.example.com ssh-ed25519 AAAA", - "|1|hashed|entry ssh-ed25519 AAAA", - "[bastion.example.com]:2222 ssh-ed25519 AAAA", - "", - ].join("\n"), - ); - - const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; - const hosts = yield* sshEnvironment.discoverHosts({ homeDir }); - assert.deepEqual(hosts, [ - { - alias: "bastion.example.com", - hostname: "bastion.example.com", - username: null, - port: null, - source: "known-hosts", - }, - { - alias: "devbox", - hostname: "devbox", - username: null, - port: null, - source: "ssh-config", - }, - { - alias: "known.example.com", - hostname: "known.example.com", - username: null, - port: null, - source: "known-hosts", - }, - { - alias: "staging", - hostname: "staging", - username: null, - port: null, - source: "ssh-config", - }, - ]); - }).pipe( - Effect.provide( - DesktopSshEnvironment.layer().pipe( - Layer.provideMerge( - Layer.succeed(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, { - request: () => Effect.die("unexpected password prompt request"), - resolve: () => Effect.die("unexpected password prompt resolution"), - }), - ), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(NodeHttpClient.layerUndici), - Layer.provideMerge(NetService.layer), - ), - ), - Effect.scoped, - ), - ); -}); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 08dd48b718f9..61b104b4ac62 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -87,7 +87,7 @@ function unexpectedPasswordPromptError(error: never): never { throw new Error(`Unhandled desktop SSH password prompt error: ${String(error)}`); } -export function toSshPasswordPromptError( +function toSshPasswordPromptError( cause: DesktopSshPasswordPrompts.DesktopSshPasswordPromptRequestError, ): SshPasswordPromptError { let message: string; diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index dc46278a1150..b86cc275df23 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -1,6 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -144,59 +143,6 @@ describe("DesktopSshPasswordPrompts", () => { }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); }); - it.effect("times out pending renderer prompts with a typed error", () => { - const testWindow = makeTestWindow(); - - return Effect.gen(function* () { - const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; - const fiber = yield* prompts - .request({ - destination: "devbox", - username: null, - prompt: "Enter the SSH password.", - attempt: 1, - }) - .pipe(Effect.forkScoped); - - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(1_000)); - const error = yield* Fiber.join(fiber).pipe(Effect.flip); - assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError); - assert.equal(error.destination, "devbox"); - }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); - }); - - it.effect("cleans up a prompt that fails during renderer delivery", () => { - const cause = new Error("renderer unavailable"); - const testWindow = makeTestWindow({ sendError: cause }); - - return Effect.gen(function* () { - const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; - const error = yield* prompts - .request({ - destination: "devbox", - username: "julius", - prompt: "Enter the SSH password.", - attempt: 1, - }) - .pipe(Effect.flip); - - assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError); - assert.equal(error.operation, "send-prompt-request"); - assert.equal(error.destination, "devbox"); - const requestId = error.requestId; - if (requestId === null) { - assert.fail("renderer delivery failures must retain their request id"); - } - assert.equal(testWindow.closedListenerCount(), 0); - - const resolveError = yield* prompts - .resolve({ requestId, password: "secret" }) - .pipe(Effect.flip); - assert.instanceOf(resolveError, DesktopSshPasswordPrompts.DesktopSshPromptExpiredError); - }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); - }); - it.effect("keeps a submitted password when a later presentation step fails", () => { const testWindow = makeTestWindow({ isMinimizedError: new Error("failed to read minimized state"), @@ -225,25 +171,4 @@ describe("DesktopSshPasswordPrompts", () => { assert.equal(testWindow.closedListenerCount(), 0); }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); }); - - it.effect("classifies a failed initial window availability check", () => { - const testWindow = makeTestWindow({ isDestroyedError: new Error("window unavailable") }); - - return Effect.gen(function* () { - const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; - const error = yield* prompts - .request({ - destination: "devbox", - username: "julius", - prompt: "Enter the SSH password.", - attempt: 1, - }) - .pipe(Effect.flip); - - assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError); - assert.equal(error.operation, "check-window-before-request"); - assert.equal(error.requestId, null); - assert.deepEqual(testWindow.sentMessages, []); - }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); - }); }); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 5e59973901c1..f3f0163401ab 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -1,18 +1,13 @@ -import { DesktopHostTelemetryMessage } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; @@ -96,396 +91,6 @@ describe("DesktopTelemetryPublisher", () => { assert.isDefined(closeFiber.pollUnsafe()); }), ); - - it.effect("publishes Electron metrics and event-driven power state over NDJSON", () => - Effect.gen(function* () { - const onBattery = yield* Ref.make(false); - const systemIdleState = yield* Ref.make("active"); - let beforeSystemIdleState: Effect.Effect = Effect.void; - let metricsReadCount = 0; - const recordedMetrics: ReadonlyArray[] = []; - const simpleListeners = new Map void>(); - let thermalListener: ((state: ElectronPowerMonitor.ElectronThermalState) => void) | null = - null; - let speedLimitListener: ((limit: number) => void) | null = null; - const metrics = [ - { - pid: 4_242, - type: "Browser", - creationTime: 1_000.75, - name: "electron", - cpu: { - percentCPUUsage: 12.5, - cumulativeCPUUsage: 3.25, - idleWakeupsPerSecond: 7, - }, - memory: { - workingSetSize: 2_048, - peakWorkingSetSize: 4_096, - }, - } as Electron.ProcessMetric, - ]; - const layerPower = Layer.succeed( - ElectronPowerMonitor.ElectronPowerMonitor, - ElectronPowerMonitor.ElectronPowerMonitor.of({ - isOnBatteryPower: Ref.get(onBattery), - getSystemIdleTime: Effect.succeed(5), - getSystemIdleState: () => - beforeSystemIdleState.pipe(Effect.andThen(Ref.get(systemIdleState))), - getCurrentThermalState: Effect.succeed("nominal"), - onSimpleEvent: (eventName, listener) => - Effect.sync(() => { - simpleListeners.set(eventName, listener); - }), - onThermalStateChange: (listener) => - Effect.sync(() => { - thermalListener = listener; - }), - onSpeedLimitChange: (listener) => - Effect.sync(() => { - speedLimitListener = listener; - }), - }), - ); - const layer = DesktopTelemetryPublisher.layer.pipe( - Layer.provide( - Layer.mergeAll( - layerElectronApp(metrics, () => { - metricsReadCount += 1; - }), - layerPower, - Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { - register: () => Effect.void, - shutdown: Effect.void, - recordMetrics: (sample) => - Effect.sync(() => { - recordedMetrics.push(sample); - }), - }), - ), - ), - ); - - yield* Effect.gen(function* () { - const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; - const encoded = yield* publisher.encoded.pipe(Stream.take(2), Stream.runCollect); - const decoder = new TextDecoder(); - const decodeMessage = Schema.decodeUnknownEffect( - Schema.fromJsonString(DesktopHostTelemetryMessage), - ); - const messages = yield* Effect.forEach(encoded, (bytes) => - decodeMessage(decoder.decode(bytes).trim()), - ); - - const hello = messages[0]; - if (hello?.type !== "desktopTelemetryHello") { - return assert.fail("Expected the first telemetry message to be the hello."); - } - assert.equal(hello.electronPid, process.pid); - const initialSnapshot = messages[1]; - if (initialSnapshot?.type !== "desktopTelemetry") { - return assert.fail("Expected the second telemetry message to be a snapshot."); - } - assert.deepEqual(initialSnapshot.electronProcesses, []); - assert.equal(initialSnapshot.electronPid, process.pid); - assert.equal(metricsReadCount, 1); - assert.deepEqual(recordedMetrics, [metrics]); - - const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild); - yield* Effect.yieldNow; - yield* publisher.handleControlForSource("primary-backend", { - version: 1, - type: "setDiagnosticsDemand", - enabled: true, - }); - const demandedSnapshot = Option.getOrThrow(yield* Fiber.join(nextSnapshotFiber)); - assert.equal(demandedSnapshot.electronProcesses[0]?.pid, 4_242); - assert.equal(demandedSnapshot.electronProcesses[0]?.creationTimeMs, 1_001); - assert.equal(demandedSnapshot.electronProcesses[0]?.cpuPercent, 12.5); - assert.equal(demandedSnapshot.electronProcesses[0]?.workingSetBytes, 2_048 * 1_024); - assert.equal(metricsReadCount, 2); - assert.deepEqual(recordedMetrics, [metrics, metrics]); - yield* publisher.handleControlForSource("secondary-backend", { - version: 1, - type: "setDiagnosticsDemand", - enabled: true, - }); - yield* publisher.handleControlForSource("primary-backend", { - version: 1, - type: "setDiagnosticsDemand", - enabled: false, - }); - yield* publisher.handleControlForSource("old-backend", { - version: 1, - type: "setDiagnosticsDemand", - enabled: true, - }); - yield* publisher.handleControlForSource("secondary-backend", { - version: 1, - type: "setDiagnosticsDemand", - enabled: false, - }); - yield* Effect.all( - [ - publisher.removeControlSource("old-backend"), - publisher.handleControlForSource("replacement-backend", { - version: 1, - type: "setDiagnosticsDemand", - enabled: true, - }), - ], - { concurrency: "unbounded", discard: true }, - ); - - const batterySnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( - Effect.forkChild, - ); - yield* Effect.yieldNow; - simpleListeners.get("on-battery")?.(); - const batterySnapshot = Option.getOrThrow(yield* Fiber.join(batterySnapshotFiber)); - assert.equal(batterySnapshot.power.onBattery, "true"); - yield* Ref.set(onBattery, true); - - const metricsAfterBatteryEvent = metricsReadCount; - yield* TestClock.adjust(Duration.millis(4_999)); - assert.equal(metricsReadCount, metricsAfterBatteryEvent); - yield* TestClock.adjust(Duration.millis(1)); - assert.equal(metricsReadCount, metricsAfterBatteryEvent + 1); - assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.onBattery, "true"); - - yield* Ref.set(onBattery, false); - const metricsBeforePolledAc = metricsReadCount; - yield* TestClock.adjust(Duration.seconds(5)); - assert.equal(metricsReadCount, metricsBeforePolledAc + 1); - assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.onBattery, "false"); - yield* TestClock.adjust(Duration.seconds(1)); - assert.equal(metricsReadCount, metricsBeforePolledAc + 2); - - const suspendedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( - Effect.forkChild, - ); - yield* Effect.yieldNow; - simpleListeners.get("suspend")?.(); - const suspendedSnapshot = Option.getOrThrow(yield* Fiber.join(suspendedSnapshotFiber)); - assert.isTrue(suspendedSnapshot.power.suspended); - - const constrainedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( - Effect.forkChild, - ); - yield* Effect.yieldNow; - thermalListener?.("serious"); - const constrainedSnapshot = Option.getOrThrow(yield* Fiber.join(constrainedSnapshotFiber)); - assert.equal(constrainedSnapshot.power.thermalState, "serious"); - assert.isTrue(constrainedSnapshot.power.suspended); - - const metricsAfterThermalEvent = metricsReadCount; - const recoveredSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( - Effect.forkChild, - ); - yield* TestClock.adjust(Duration.millis(14_999)); - assert.equal(metricsReadCount, metricsAfterThermalEvent); - yield* TestClock.adjust(Duration.millis(1)); - assert.equal(metricsReadCount, metricsAfterThermalEvent + 1); - const recoveredSnapshot = Option.getOrThrow(yield* Fiber.join(recoveredSnapshotFiber)); - assert.isFalse(recoveredSnapshot.power.suspended); - - const speedLimitSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( - Effect.forkChild, - ); - yield* Effect.yieldNow; - speedLimitListener?.(65); - const speedLimitSnapshot = Option.getOrThrow(yield* Fiber.join(speedLimitSnapshotFiber)); - assert.equal(Option.getOrNull(speedLimitSnapshot.speedLimitPercent), 65); - - const encodedSpeedLimit = yield* publisher.encoded.pipe(Stream.take(2), Stream.runCollect); - const decodedSpeedLimit = yield* decodeMessage(decoder.decode(encodedSpeedLimit[1]).trim()); - if (decodedSpeedLimit.type !== "desktopTelemetry") { - return assert.fail("Expected the encoded telemetry message to be a snapshot."); - } - assert.equal(Option.getOrNull(decodedSpeedLimit.speedLimitPercent), 65); - assert.equal(decodedSpeedLimit.electronProcesses[0]?.pid, 4_242); - assert.equal(decodedSpeedLimit.electronProcesses[0]?.creationTimeMs, 1_001); - - const metricsBeforeSecondaryOnlySample = metricsReadCount; - yield* TestClock.adjust(Duration.seconds(15)); - assert.equal(metricsReadCount, metricsBeforeSecondaryOnlySample + 1); - assert.equal( - (yield* publisher.latest).pipe(Option.getOrThrow).electronProcesses[0]?.pid, - 4_242, - ); - - const stoppedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( - Effect.forkChild, - ); - yield* Effect.yieldNow; - yield* publisher.handleControlForSource("replacement-backend", { - version: 1, - type: "setDiagnosticsDemand", - enabled: false, - }); - const stoppedSnapshot = Option.getOrThrow(yield* Fiber.join(stoppedSnapshotFiber)); - assert.deepEqual(stoppedSnapshot.electronProcesses, []); - const metricsAfterStopping = metricsReadCount; - const configuredSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( - Effect.forkChild, - ); - yield* Effect.yieldNow; - yield* publisher.handleControlForSource("primary-backend", { - version: 1, - type: "setHostPowerIntervals", - activeIntervalMs: 7_000, - idleIntervalMs: 11_000, - }); - const configuredSequence = Option.getOrThrow( - yield* Fiber.join(configuredSnapshotFiber), - ).sequence; - - yield* TestClock.adjust(Duration.millis(6_999)); - assert.equal( - (yield* publisher.latest).pipe(Option.getOrThrow).sequence, - configuredSequence, - ); - assert.equal(metricsReadCount, metricsAfterStopping + 1); - yield* TestClock.adjust(Duration.millis(1)); - assert.equal( - (yield* publisher.latest).pipe(Option.getOrThrow).sequence, - configuredSequence + 1, - ); - assert.equal(metricsReadCount, metricsAfterStopping + 2); - - yield* Ref.set(systemIdleState, "locked"); - yield* TestClock.adjust(Duration.seconds(7)); - const lockedSequence = (yield* publisher.latest).pipe(Option.getOrThrow).sequence; - assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.locked, "true"); - yield* TestClock.adjust(Duration.millis(10_999)); - assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).sequence, lockedSequence); - yield* TestClock.adjust(Duration.millis(1)); - assert.equal( - (yield* publisher.latest).pipe(Option.getOrThrow).sequence, - lockedSequence + 1, - ); - - yield* Ref.set(systemIdleState, "active"); - yield* TestClock.adjust(Duration.seconds(11)); - const unlockedSnapshot = (yield* publisher.latest).pipe(Option.getOrThrow); - assert.equal(unlockedSnapshot.power.locked, "false"); - assert.equal(unlockedSnapshot.power.idle, "false"); - - yield* TestClock.adjust(Duration.millis(6_999)); - assert.equal( - (yield* publisher.latest).pipe(Option.getOrThrow).sequence, - unlockedSnapshot.sequence, - ); - yield* TestClock.adjust(Duration.millis(1)); - assert.equal( - (yield* publisher.latest).pipe(Option.getOrThrow).sequence, - unlockedSnapshot.sequence + 1, - ); - - const pollStarted = yield* Deferred.make(); - const releasePoll = yield* Deferred.make(); - beforeSystemIdleState = Deferred.succeed(pollStarted, undefined).pipe( - Effect.andThen(Deferred.await(releasePoll)), - ); - const concurrentEventSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( - Effect.forkChild, - ); - yield* Effect.yieldNow; - yield* publisher.handleControlForSource("primary-backend", { - version: 1, - type: "setDiagnosticsDemand", - enabled: true, - }); - yield* Deferred.await(pollStarted); - simpleListeners.get("lock-screen")?.(); - thermalListener?.("critical"); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Deferred.succeed(releasePoll, undefined); - - const concurrentEventSnapshot = Option.getOrThrow( - yield* Fiber.join(concurrentEventSnapshotFiber), - ); - assert.equal(concurrentEventSnapshot.power.locked, "true"); - assert.equal(concurrentEventSnapshot.power.thermalState, "critical"); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect("routes requestDesktopUpdate control messages and replays update reports", () => - Effect.gen(function* () { - const layerPower = Layer.succeed( - ElectronPowerMonitor.ElectronPowerMonitor, - ElectronPowerMonitor.ElectronPowerMonitor.of({ - isOnBatteryPower: Effect.succeed(false), - getSystemIdleTime: Effect.succeed(0), - getSystemIdleState: () => Effect.succeed("active"), - getCurrentThermalState: Effect.succeed("nominal"), - onSimpleEvent: () => Effect.void, - onThermalStateChange: () => Effect.void, - onSpeedLimitChange: () => Effect.void, - }), - ); - const layer = DesktopTelemetryPublisher.layer.pipe( - Layer.provide(Layer.mergeAll(layerElectronApp([]), layerPower, layerHistory)), - ); - - yield* Effect.gen(function* () { - const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; - - const requestFiber = yield* Stream.runHead(publisher.updateRequests).pipe(Effect.forkChild); - yield* Effect.yieldNow; - yield* publisher.handleControlForSource("test", { - version: 1, - type: "requestDesktopUpdate", - requestId: "req-9", - }); - const received = yield* Fiber.join(requestFiber); - assert.equal(Option.getOrThrow(received).requestId, "req-9"); - - const report = { - version: 1, - type: "desktopUpdateStatus", - outcome: "up-to-date", - state: { - enabled: true, - status: "up-to-date", - channel: "latest", - currentVersion: "1.2.3", - hostArch: "arm64", - appArch: "arm64", - runningUnderArm64Translation: false, - availableVersion: null, - downloadedVersion: null, - releaseNotes: [], - downloadPercent: null, - checkedAt: null, - message: null, - errorContext: null, - canRetry: false, - omittedReleaseCount: 0, - }, - } as const; - yield* publisher.publishUpdateReport(report); - - const decoder = new TextDecoder(); - const decodeMessage = Schema.decodeUnknownEffect( - Schema.fromJsonString(DesktopHostTelemetryMessage), - ); - const replayed = yield* publisher.encoded.pipe( - Stream.mapEffect((bytes) => decodeMessage(decoder.decode(bytes).trim())), - Stream.filter((message) => message.type === "desktopUpdateStatus"), - Stream.runHead, - ); - const replayedReport = Option.getOrThrow(replayed); - if (replayedReport.type !== "desktopUpdateStatus") { - return assert.fail("Expected a desktop update status report."); - } - assert.equal(replayedReport.outcome, "up-to-date"); - assert.equal(replayedReport.state.currentVersion, "1.2.3"); - }).pipe(Effect.provide(layer)); - }), - ); }); describe("DesktopRendererHistory", () => { diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index 1155a2b15b0c..5e4b1de81b83 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -199,96 +199,98 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { ), ).pipe(Effect.forkScoped); - const sampleOnce = (allowSuspendRecovery: boolean) => - Effect.gen(function* () { - const sampledAt = yield* DateTime.now; - const sampledAtUnixMs = DateTime.toEpochMillis(sampledAt); - const demand = (yield* Ref.get(diagnosticsDemandSources)).size > 0; - const [currentPower, idleSeconds, systemIdleState, onBattery, metrics] = yield* Effect.all( - [ - Ref.get(powerState), - powerMonitor.getSystemIdleTime, - powerMonitor.getSystemIdleState(IDLE_THRESHOLD_SECONDS), - powerMonitor.isOnBatteryPower, - electronApp.getAppMetrics, - ], - { concurrency: "unbounded" }, - ); - // One Electron sampler preserves the shared CPU measurement interval. - yield* rendererHistory.recordMetrics(metrics); - const polledLocked = - systemIdleState === "unknown" - ? currentPower.locked - : booleanState(systemIdleState === "locked"); - const polledOnBattery = booleanState(onBattery); - const observedPower = yield* Ref.modify(powerState, (latestPower) => { - const preserveEventLocked = - latestPower.lockedEventPending || - latestPower.locked !== currentPower.locked || - latestPower.lockedEventPending !== currentPower.lockedEventPending; - const preserveEventOnBattery = - latestPower.onBatteryEventPending || - latestPower.onBattery !== currentPower.onBattery || - latestPower.onBatteryEventPending !== currentPower.onBatteryEventPending; - const preserveEventSuspended = - latestPower.suspendedEventPending || - latestPower.suspended !== currentPower.suspended || - latestPower.suspendedEventPending !== currentPower.suspendedEventPending; - const next: PowerState = { - ...latestPower, - idle: idleState(systemIdleState), - locked: preserveEventLocked ? latestPower.locked : polledLocked, - lockedEventPending: - latestPower.lockedEventPending && - (systemIdleState === "unknown" || polledLocked !== latestPower.locked), - suspended: - allowSuspendRecovery && !preserveEventSuspended ? false : latestPower.suspended, - suspendedEventPending: false, - onBattery: preserveEventOnBattery ? latestPower.onBattery : polledOnBattery, - onBatteryEventPending: - latestPower.onBatteryEventPending && polledOnBattery !== latestPower.onBattery, + const sampleOnce = + /* Periodic telemetry sampling should not generate its own tracing traffic. */ + Effect.fnUntraced( + function* (allowSuspendRecovery: boolean) { + const sampledAt = yield* DateTime.now; + const sampledAtUnixMs = DateTime.toEpochMillis(sampledAt); + const demand = (yield* Ref.get(diagnosticsDemandSources)).size > 0; + const [currentPower, idleSeconds, systemIdleState, onBattery, metrics] = yield* Effect.all( + [ + Ref.get(powerState), + powerMonitor.getSystemIdleTime, + powerMonitor.getSystemIdleState(IDLE_THRESHOLD_SECONDS), + powerMonitor.isOnBatteryPower, + electronApp.getAppMetrics, + ], + { concurrency: "unbounded" }, + ); + // One Electron sampler preserves the shared CPU measurement interval. + yield* rendererHistory.recordMetrics(metrics); + const polledLocked = + systemIdleState === "unknown" + ? currentPower.locked + : booleanState(systemIdleState === "locked"); + const polledOnBattery = booleanState(onBattery); + const observedPower = yield* Ref.modify(powerState, (latestPower) => { + const preserveEventLocked = + latestPower.lockedEventPending || + latestPower.locked !== currentPower.locked || + latestPower.lockedEventPending !== currentPower.lockedEventPending; + const preserveEventOnBattery = + latestPower.onBatteryEventPending || + latestPower.onBattery !== currentPower.onBattery || + latestPower.onBatteryEventPending !== currentPower.onBatteryEventPending; + const preserveEventSuspended = + latestPower.suspendedEventPending || + latestPower.suspended !== currentPower.suspended || + latestPower.suspendedEventPending !== currentPower.suspendedEventPending; + const next: PowerState = { + ...latestPower, + idle: idleState(systemIdleState), + locked: preserveEventLocked ? latestPower.locked : polledLocked, + lockedEventPending: + latestPower.lockedEventPending && + (systemIdleState === "unknown" || polledLocked !== latestPower.locked), + suspended: + allowSuspendRecovery && !preserveEventSuspended ? false : latestPower.suspended, + suspendedEventPending: false, + onBattery: preserveEventOnBattery ? latestPower.onBattery : polledOnBattery, + onBatteryEventPending: + latestPower.onBatteryEventPending && polledOnBattery !== latestPower.onBattery, + }; + return [next, next] as const; + }); + const nextSequence = yield* Ref.modify(sequence, (current) => [current + 1, current + 1]); + const snapshot: DesktopHostTelemetrySnapshot = { + version: 1, + type: "desktopTelemetry", + sequence: nextSequence, + sampledAtUnixMs, + electronPid: process.pid, + power: { + source: "electron-main", + idle: observedPower.idle, + idleSeconds, + locked: observedPower.locked, + suspended: observedPower.suspended, + onBattery: observedPower.onBattery, + lowPowerMode: "unknown", + thermalState: observedPower.thermalState, + stale: false, + updatedAt: sampledAt, + }, + speedLimitPercent: observedPower.speedLimitPercent, + electronProcesses: (demand ? metrics : []).map((metric) => ({ + pid: metric.pid, + creationTimeMs: Math.max(0, Math.round(metric.creationTime)), + type: metric.type, + ...(metric.name === undefined ? {} : { name: metric.name }), + ...(metric.serviceName === undefined ? {} : { serviceName: metric.serviceName }), + cpuPercent: metric.cpu.percentCPUUsage, + ...(metric.cpu.cumulativeCPUUsage === undefined + ? {} + : { cumulativeCpuSeconds: metric.cpu.cumulativeCPUUsage }), + idleWakeupsPerSecond: metric.cpu.idleWakeupsPerSecond, + workingSetBytes: Math.max(0, Math.round(metric.memory.workingSetSize * 1024)), + peakWorkingSetBytes: Math.max(0, Math.round(metric.memory.peakWorkingSetSize * 1024)), + })), }; - return [next, next] as const; - }); - const nextSequence = yield* Ref.modify(sequence, (current) => [current + 1, current + 1]); - const snapshot: DesktopHostTelemetrySnapshot = { - version: 1, - type: "desktopTelemetry", - sequence: nextSequence, - sampledAtUnixMs, - electronPid: process.pid, - power: { - source: "electron-main", - idle: observedPower.idle, - idleSeconds, - locked: observedPower.locked, - suspended: observedPower.suspended, - onBattery: observedPower.onBattery, - lowPowerMode: "unknown", - thermalState: observedPower.thermalState, - stale: false, - updatedAt: sampledAt, - }, - speedLimitPercent: observedPower.speedLimitPercent, - electronProcesses: (demand ? metrics : []).map((metric) => ({ - pid: metric.pid, - creationTimeMs: Math.max(0, Math.round(metric.creationTime)), - type: metric.type, - ...(metric.name === undefined ? {} : { name: metric.name }), - ...(metric.serviceName === undefined ? {} : { serviceName: metric.serviceName }), - cpuPercent: metric.cpu.percentCPUUsage, - ...(metric.cpu.cumulativeCPUUsage === undefined - ? {} - : { cumulativeCpuSeconds: metric.cpu.cumulativeCPUUsage }), - idleWakeupsPerSecond: metric.cpu.idleWakeupsPerSecond, - workingSetBytes: Math.max(0, Math.round(metric.memory.workingSetSize * 1024)), - peakWorkingSetBytes: Math.max(0, Math.round(metric.memory.peakWorkingSetSize * 1024)), - })), - }; - yield* Ref.set(latest, Option.some(snapshot)); - yield* PubSub.publish(changes, snapshot); - }).pipe( + yield* Ref.set(latest, Option.some(snapshot)); + yield* PubSub.publish(changes, snapshot); + }, Effect.catchCauseIf( (cause) => !Cause.hasInterrupts(cause), (cause) => diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index 1fc0a9e39be5..49acc3f86a48 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -484,69 +484,6 @@ describe("DesktopRemoteUpdates", () => { ); }); - it.effect("fails a prepared install when its background check stays blocked", () => { - const backgroundCheckStarted = Deferred.makeUnsafe(); - const releaseBackgroundCheck = Deferred.makeUnsafe(); - let checks = 0; - const harness = makeHarness({ - checkForUpdates: Effect.suspend(() => { - checks += 1; - return checks === 2 - ? Deferred.succeed(backgroundCheckStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseBackgroundCheck)), - ) - : Effect.void; - }), - }); - - return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* Queue.offer(requests, request("req-blocked-background-check")); - yield* settle; - harness.emit("update-available", { version: "1.2.4" }); - yield* settle; - harness.emit("update-downloaded", { version: "1.2.4" }); - yield* settle; - - const backgroundCheck = yield* updates.check("poll").pipe(Effect.forkChild); - yield* Deferred.await(backgroundCheckStarted); - const commit = { - version: 1, - type: "commitDesktopUpdate", - requestId: "req-blocked-background-check", - } as const; - yield* Queue.offer(commits, commit); - yield* settle; - yield* TestClock.adjust(Duration.seconds(91)); - yield* settle; - - assert.equal(harness.quitAndInstalls(), 0); - assert.deepEqual( - terminalReports(reports) - .filter((report) => report.requestId === "req-blocked-background-check") - .map((report) => report.outcome), - ["ready-to-install", "failed"], - ); - - harness.emit("update-downloaded", { version: "1.2.4" }); - yield* Deferred.succeed(releaseBackgroundCheck, undefined); - yield* Fiber.join(backgroundCheck); - yield* settle; - assert.equal(harness.quitAndInstalls(), 0); - - yield* Queue.offer(commits, commit); - yield* settle; - assert.deepEqual( - terminalReports(reports) - .filter((report) => report.requestId === "req-blocked-background-check") - .map((report) => report.outcome), - ["ready-to-install", "failed", "failed"], - ); - }), - ); - }); - it.effect("rejects a new preparation while an install commit is active", () => { const installStarted = Deferred.makeUnsafe(); const releaseInstall = Deferred.makeUnsafe(); @@ -640,32 +577,6 @@ describe("DesktopRemoteUpdates", () => { ); }); - it.effect("keeps every queued cancellation until its request starts", () => { - const harness = makeHarness(); - - return runRemoteUpdatesTest(harness, ({ requests, cancellations }) => - Effect.gen(function* () { - for (let index = 0; index < 40; index += 1) { - yield* Queue.offer(cancellations, { - version: 1, - type: "cancelDesktopUpdate", - requestId: `req-queued-cancel-${index}`, - }); - } - yield* settle; - for (let index = 0; index < 40; index += 1) { - yield* Queue.offer(requests, request(`req-queued-cancel-${index}`)); - } - yield* settle; - assert.equal(harness.checkCount(), 0); - - yield* Queue.offer(requests, request("req-after-queued-cancels")); - yield* settle; - assert.equal(harness.checkCount(), 1); - }), - ); - }); - it.effect("does not install after cancellation wins the commit claim", () => { const harness = makeHarness(); @@ -757,34 +668,6 @@ describe("DesktopRemoteUpdates", () => { ); }); - it.effect("expires an uncommitted preparation", () => { - const harness = makeHarness(); - - return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => - Effect.gen(function* () { - yield* Queue.offer(requests, request("req-stale")); - yield* settle; - harness.emit("update-available", { version: "1.2.4" }); - yield* settle; - harness.emit("update-downloaded", { version: "1.2.4" }); - yield* settle; - - yield* TestClock.setTime(Duration.toMillis(Duration.minutes(6))); - yield* Queue.offer(commits, { - version: 1, - type: "commitDesktopUpdate", - requestId: "req-stale", - }); - yield* settle; - - const outcomes = terminalReports(reports) - .filter((report) => report.requestId === "req-stale") - .map((report) => report.outcome); - assert.deepEqual(outcomes, ["ready-to-install", "failed"]); - }), - ); - }); - it.effect("reports up-to-date without installing when there is no update", () => { const harness = makeHarness(); @@ -803,24 +686,4 @@ describe("DesktopRemoteUpdates", () => { }), ); }); - - it.effect("fails fast with the disabled reason when updates are off", () => { - const harness = makeHarness({ env: { T3CODE_DISABLE_AUTO_UPDATE: "true" } }); - - return runRemoteUpdatesTest(harness, ({ reports, requests }) => - Effect.gen(function* () { - yield* Queue.offer(requests, request("req-3")); - yield* settle; - - const terminals = terminalReports(reports); - assert.equal(terminals.length, 1); - assert.equal(terminals[0]?.outcome, "failed"); - assert.equal( - terminals[0]?.reason, - "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.", - ); - assert.equal(harness.quitAndInstalls(), 0); - }), - ); - }); }); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts index 716a73c199aa..cb1b86c07754 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -188,84 +188,85 @@ export const listen: Effect.Effect< Effect.forkScoped, ); - const step = (state: DesktopUpdateState): Effect.Effect => - Effect.gen(function* () { - const next = nextRemoteDesktopUpdateStep(state, attempts, disabledReason); - switch (next.action) { - case "wait": - return false; - case "check": - attempts = { ...attempts, checks: attempts.checks + 1 }; - yield* updates.check("remote-update").pipe( - Effect.flatMap((result) => { - if (result.checked) return Effect.void; - attempts = { ...attempts, checks: attempts.checks - 1 }; - return retryLater(state); - }), - Effect.forkScoped, - ); + const step = Effect.fn("DesktopRemoteUpdates.step")(function* ( + state: DesktopUpdateState, + ): Effect.fn.Return { + const next = nextRemoteDesktopUpdateStep(state, attempts, disabledReason); + switch (next.action) { + case "wait": + return false; + case "check": + attempts = { ...attempts, checks: attempts.checks + 1 }; + yield* updates.check("remote-update").pipe( + Effect.flatMap((result) => { + if (result.checked) return Effect.void; + attempts = { ...attempts, checks: attempts.checks - 1 }; + return retryLater(state); + }), + Effect.forkScoped, + ); + return false; + case "download": + attempts = { ...attempts, downloads: attempts.downloads + 1 }; + yield* updates.download.pipe( + Effect.flatMap((result) => { + if (result.accepted) return Effect.void; + attempts = { ...attempts, downloads: attempts.downloads - 1 }; + return retryLater(state); + }), + Effect.forkScoped, + ); + return false; + case "install": { + // The download event fires before its action releases the + // updater reservation. Wait until the prepared install can + // be committed by the client in a separate RPC. + if (yield* updates.isActionActive) { + yield* retryLater(state); return false; - case "download": - attempts = { ...attempts, downloads: attempts.downloads + 1 }; - yield* updates.download.pipe( - Effect.flatMap((result) => { - if (result.accepted) return Effect.void; - attempts = { ...attempts, downloads: attempts.downloads - 1 }; - return retryLater(state); - }), - Effect.forkScoped, - ); - return false; - case "install": { - // The download event fires before its action releases the - // updater reservation. Wait until the prepared install can - // be committed by the client in a separate RPC. - if (yield* updates.isActionActive) { - yield* retryLater(state); - return false; - } - if (state.downloadedVersion === null) { - yield* publishReport( - state, - { - outcome: "failed", - reason: "The desktop app lost the downloaded update.", - }, - request.requestId, - ); - return true; - } - yield* Ref.set( - preparedUpdateRef, - Option.some({ - requestId: request.requestId, - downloadedVersion: state.downloadedVersion, - status: "prepared", - expiresAt: - (yield* Clock.currentTimeMillis) + Duration.toMillis(PREPARED_UPDATE_TTL), - }), - ); - yield* publishReport(state, { outcome: "ready-to-install" }, request.requestId); - yield* logInfo("remote update prepared", { requestId: request.requestId }); - return true; } - case "done": + if (state.downloadedVersion === null) { yield* publishReport( state, { - outcome: next.outcome, - ...(next.reason === undefined ? {} : { reason: next.reason }), + outcome: "failed", + reason: "The desktop app lost the downloaded update.", }, request.requestId, ); - yield* logInfo("remote update finished", { - requestId: request.requestId, - outcome: next.outcome, - reason: next.reason ?? null, - }); return true; + } + yield* Ref.set( + preparedUpdateRef, + Option.some({ + requestId: request.requestId, + downloadedVersion: state.downloadedVersion, + status: "prepared", + expiresAt: + (yield* Clock.currentTimeMillis) + Duration.toMillis(PREPARED_UPDATE_TTL), + }), + ); + yield* publishReport(state, { outcome: "ready-to-install" }, request.requestId); + yield* logInfo("remote update prepared", { requestId: request.requestId }); + return true; } - }); + case "done": + yield* publishReport( + state, + { + outcome: next.outcome, + ...(next.reason === undefined ? {} : { reason: next.reason }), + }, + request.requestId, + ); + yield* logInfo("remote update finished", { + requestId: request.requestId, + outcome: next.outcome, + reason: next.reason ?? null, + }); + return true; + } + }); yield* Effect.raceFirst( Effect.gen(function* () { diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 2a0d1d453092..d2f24143fc20 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -2,7 +2,6 @@ import { assert, describe, it } from "@effect/vitest"; import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -22,72 +21,6 @@ import * as DesktopUpdates from "./DesktopUpdates.ts"; import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; describe("DesktopUpdates", () => { - it("preserves complete causes for update poller and event failures", () => { - const cause = Cause.combine( - Cause.fail(new Error("updater failed")), - Cause.die(new Error("updater defect")), - ); - const pollerError = new DesktopUpdates.DesktopUpdatePollerError({ - poller: "startup", - cause, - }); - const eventError = new DesktopUpdates.DesktopUpdateEventHandlingError({ - event: "download-progress", - cause, - }); - const reportedError = new DesktopUpdates.DesktopUpdaterReportedError({ - operation: "download", - cause, - }); - const unexpectedActionError = new DesktopUpdates.DesktopUpdateUnexpectedActionError({ - action: "install", - cause, - }); - - assert.strictEqual(pollerError.cause, cause); - assert.equal(pollerError.poller, "startup"); - assert.equal(pollerError.message, "Desktop update startup poller failed."); - assert.strictEqual(eventError.cause, cause); - assert.equal(eventError.event, "download-progress"); - assert.equal(eventError.message, "Failed to handle desktop update download-progress event."); - assert.strictEqual(reportedError.cause, cause); - assert.equal(reportedError.operation, "download"); - assert.equal(reportedError.message, "Desktop updater download operation reported an error."); - assert.strictEqual(unexpectedActionError.cause, cause); - assert.equal(unexpectedActionError.action, "install"); - assert.equal( - unexpectedActionError.message, - "Desktop update install action failed unexpectedly.", - ); - }); - - it.effect("configures the updater and runs startup checks on the test clock", () => { - const harness = makeHarness(); - - return Effect.gen(function* () { - yield* Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const state = yield* updates.getState; - assert.equal(state.enabled, true); - assert.equal(state.status, "idle"); - assert.deepEqual(harness.feedUrls(), [ - { provider: "generic", url: "http://localhost:4141" }, - ]); - assert.equal(harness.listenerCount(), 6); - assert.equal(harness.checkCount(), 0); - - yield* TestClock.adjust(Duration.millis(15_000)); - assert.equal(harness.checkCount(), 1); - }), - ); - - assert.equal(harness.listenerCount(), 0); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () => Effect.gen(function* () { const linuxState = (packageType: string | undefined) => @@ -107,103 +40,27 @@ describe("DesktopUpdates", () => { }), ); - it.effect("subscribe delivers the latest state plus subsequent changes", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const { latest, changes } = yield* updates.subscribe; - assert.equal(latest.status, "idle"); - - const nextState = yield* Stream.runHead(changes).pipe(Effect.forkChild); - yield* flushCallbacks; - harness.emit("update-available", { version: "1.2.4" }); - yield* flushCallbacks; - - const observed = yield* Fiber.join(nextState); - assert.equal(Option.getOrThrow(observed).status, "available"); - assert.equal(Option.getOrThrow(observed).availableVersion, "1.2.4"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("updates and broadcasts state from updater events", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - harness.emit("update-available", { version: "1.2.4" }); - yield* flushCallbacks; - - const state = yield* updates.getState; - assert.equal(state.status, "available"); - assert.equal(state.availableVersion, "1.2.4"); - assert.isNotNull(state.checkedAt); - assert.equal(harness.sentStates.at(-1)?.status, "available"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("enables nightly full changelog release notes and broadcasts summaries", () => { - const harness = makeHarness(); + it.effect("keeps the disabled reason available after configuration and channel changes", () => { + const harness = makeHarness({ env: { T3CODE_DISABLE_AUTO_UPDATE: "true" } }); + const reason = "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting."; return Effect.scoped( Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; yield* updates.configure; - yield* updates.setChannel("nightly"); - assert.equal(harness.fullChangelog(), true); + const configured = yield* updates.getState; + assert.equal(configured.message, reason); - harness.emit("update-available", { - version: "1.2.4-nightly.20260709.766", - releaseNotes: [ - { - version: "1.2.4-nightly.20260709.766", - note: `

What's Changed

Full Changelog

`, - }, - { - version: "1.2.4-nightly.20260709.765", - note: "- [codex] Upgrade Clerk stack by @juliusmarminge in #3821", - }, - { version: "1.2.4-nightly.20260709.764", note: "- Change 764" }, - { version: "1.2.4-nightly.20260709.763", note: "- Change 763" }, - { version: "1.2.4-nightly.20260709.762", note: "- Change 762" }, - { version: "1.2.4-nightly.20260709.761", note: "- Change 761" }, - { version: "1.2.4-nightly.20260709.760", note: "- Change 760" }, - ], - }); - yield* flushCallbacks; - - const state = yield* updates.getState; - assert.equal(state.status, "available"); - assert.deepEqual(state.releaseNotes, [ - { - version: "1.2.4-nightly.20260709.766", - items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"], - totalItems: 1, - }, - { - version: "1.2.4-nightly.20260709.765", - items: ["[codex] Upgrade Clerk stack by @juliusmarminge in #3821"], - totalItems: 1, - }, - { version: "1.2.4-nightly.20260709.764", items: ["Change 764"], totalItems: 1 }, - { version: "1.2.4-nightly.20260709.763", items: ["Change 763"], totalItems: 1 }, - { version: "1.2.4-nightly.20260709.762", items: ["Change 762"], totalItems: 1 }, - { version: "1.2.4-nightly.20260709.761", items: ["Change 761"], totalItems: 1 }, - ]); - assert.equal(state.omittedReleaseCount, 1); - assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes); - assert.equal(harness.sentStates.at(-1)?.omittedReleaseCount, 1); + const changed = yield* updates.setChannel("nightly"); + assert.equal(changed.channel, "nightly"); + assert.equal(changed.enabled, false); + assert.equal(changed.status, "disabled"); + assert.equal(changed.message, reason); + assert.equal(harness.sentStates.at(-1)?.message, reason); + assert.equal(harness.checkCount(), 0); }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + ).pipe(Effect.provide(harness.layer)); }); it.effect("checks for newer releases after an update has been downloaded", () => { @@ -283,39 +140,6 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); - it.effect("preserves a queued installer when the feed offers another channel", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - harness.emit("update-available", { - version: "1.2.4", - releaseNotes: "## What's changed\n- fix: queued update", - }); - yield* flushCallbacks; - harness.emit("update-downloaded", { version: "1.2.4" }); - yield* flushCallbacks; - - yield* updates.check("poll"); - harness.emit("update-available", { version: "1.2.5-nightly.20260710.1" }); - yield* flushCallbacks; - - const state = yield* updates.getState; - assert.equal(state.status, "downloaded"); - assert.equal(state.availableVersion, "1.2.4"); - assert.equal(state.downloadedVersion, "1.2.4"); - assert.deepEqual(state.releaseNotes, [ - { version: "1.2.4", items: ["fix: queued update"], totalItems: 1 }, - ]); - assert.equal(state.omittedReleaseCount, 0); - assert.equal(state.downloadPercent, 100); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - it.effect( "rejects install while a refresh check is in progress and releases the reservation", () => @@ -385,28 +209,6 @@ describe("DesktopUpdates", () => { }), ); - it.effect("keeps raw updater event failures out of update state", () => { - const harness = makeHarness(); - const cause = new Error( - "request failed for https://user:secret@example.com/update?token=secret", - ); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - harness.emit("error", cause); - yield* flushCallbacks; - - const state = yield* updates.getState; - assert.equal(state.status, "error"); - assert.equal(state.message, "Desktop updater background operation reported an error."); - assert.notInclude(state.message ?? "", "secret"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - it.effect("preserves a queued installer after a background updater error", () => { const harness = makeHarness(); @@ -484,49 +286,6 @@ describe("DesktopUpdates", () => { ); }); - it.effect("logs the redacted cause of an updater error event", () => { - const harness = makeHarness(); - const cause = Object.assign(new Error("404 Not Found\nHeaders: authorization=secret"), { - code: "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND", - }); - const loggedAnnotations: Array> = []; - const logger = Logger.make(({ fiber }) => { - const annotations = fiber.getRef(References.CurrentLogAnnotations); - if (annotations.errorTag === "DesktopUpdaterReportedError") { - loggedAnnotations.push(annotations); - } - }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - harness.emit("error", cause); - yield* flushCallbacks; - - const loggedAnnotation = loggedAnnotations.at(-1); - assert.isDefined(loggedAnnotation); - assert.equal(loggedAnnotation.operation, "background"); - assert.equal( - loggedAnnotation.cause, - "Error [ERR_UPDATER_CHANNEL_FILE_NOT_FOUND]: 404 Not Found", - ); - assert.notInclude(JSON.stringify(loggedAnnotations), "secret"); - const state = yield* updates.getState; - assert.equal(state.message, "Desktop updater background operation reported an error."); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - TestClock.layer(), - harness.layer, - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); - }); - it.effect("recovers download state after an unexpected setup failure", () => { let disableDifferentialCalls = 0; const harness = makeHarness({ @@ -698,6 +457,7 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + // regression: #19291 an install that did apply was still flagged as not applied it.effect("does not flag an install that applied", () => { const harness = makeHarness({ installRecord: JSON.stringify({ fromVersion: "1.2.2", targetVersion: "1.2.3" }), @@ -898,29 +658,6 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); - it.effect("recovers when quitAndInstall reports failure through an updater event", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const desktopState = yield* DesktopState.DesktopState; - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - harness.emit("update-downloaded", { version: "1.2.4" }); - yield* flushCallbacks; - - yield* updates.install; - assert.deepEqual(harness.installSteps, ["quitAndInstall"]); - harness.emit("error", new Error("native installer refused")); - yield* flushCallbacks; - - assert.isFalse(yield* Ref.get(desktopState.quitting)); - assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); - assert.equal((yield* updates.getState).errorContext, "install"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - it.effect("rejects a prepared install when the downloaded version changed", () => { const harness = makeHarness(); @@ -976,40 +713,6 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); - it.effect("fails channel changes with a typed error while a check is in progress", () => - Effect.gen(function* () { - const checkStarted = yield* Deferred.make(); - const releaseCheck = yield* Deferred.make(); - const harness = makeHarness({ - checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseCheck)), - ), - }); - - yield* Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped); - yield* Deferred.await(checkStarted); - - const exit = yield* Effect.exit(updates.setChannel("nightly")); - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError); - assert.equal(error.action, "check"); - assert.equal(error.requestedChannel, "nightly"); - } - - yield* Deferred.succeed(releaseCheck, undefined); - yield* Fiber.join(checkFiber); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }), - ); - it.effect("rejects checks while an update channel change is being persisted", () => Effect.gen(function* () { const channelChangeStarted = yield* Deferred.make(); @@ -1041,52 +744,9 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }), ); - - it.effect("preserves settings failure context when an update channel cannot be persisted", () => { - const diskFailure = new Error("disk exploded"); - const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ - operation: "replace-settings-file", - path: "/tmp/settings.json", - cause: diskFailure, - }); - const harness = makeHarness({ setUpdateChannelError: settingsFailure }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const error = yield* updates.setChannel("nightly").pipe(Effect.flip); - - assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError); - assert.equal(error.channel, "nightly"); - assert.strictEqual(error.cause, settingsFailure); - assert.strictEqual(error.cause.cause, diskFailure); - assert.equal(error.message, "Failed to persist the nightly desktop update channel."); - assert.notInclude(error.message, diskFailure.message); - - const checkResult = yield* updates.check("manual"); - assert.isTrue(checkResult.checked); - assert.equal(harness.checkCount(), 1); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); }); describe("describeUpdateErrorCause", () => { - it("preserves network and nested failure reasons", () => { - const networkError = Object.assign(new Error("getaddrinfo ENOTFOUND updates.example.com"), { - code: "ENOTFOUND", - }); - const cause = new Error("fetch failed", { cause: networkError }); - - assert.equal( - DesktopUpdates.describeUpdateErrorCause(Cause.fail(cause)), - "Error: fetch failed; caused by Error [ENOTFOUND]: getaddrinfo ENOTFOUND updates.example.com", - ); - assert.include(DesktopUpdates.describeUpdateErrorCause(Cause.die(networkError)), "[ENOTFOUND]"); - }); - it("removes URLs, credentials, response bodies, and headers from summaries", () => { const messages = [ "Cannot download https://user:url-secret@example.com/update?token=query-secret, status 403", diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index b2d93f407ac7..ae3c2588fab2 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -273,13 +273,15 @@ function parseAppUpdateYml(raw: string): Effect.Effect, environment: DesktopEnvironment.DesktopEnvironment["Service"], ): DesktopUpdateState { + const enabled = Option.isNone(disabledReason); return { ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel), enabled, status: enabled ? "idle" : "disabled", + message: Option.getOrNull(disabledReason), }; } @@ -468,8 +470,6 @@ export const make = Effect.gen(function* () { }); }); - const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone)); - const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* ( reason: string, actionReservation: "acquire" | "held" = "acquire", @@ -631,10 +631,10 @@ export const make = Effect.gen(function* () { ), ); - const baseUpdateState = (channel: DesktopUpdateChannel, enabled: boolean) => + const baseUpdateState = (channel: DesktopUpdateChannel, disabledReason: Option.Option) => Ref.get(notAppliedVersionRef).pipe( Effect.map((notAppliedVersion) => ({ - ...createBaseUpdateState(channel, enabled, environment), + ...createBaseUpdateState(channel, disabledReason, environment), ...Option.match(notAppliedVersion, { onNone: () => ({}), onSome: (version) => ({ notAppliedVersion: version }), @@ -1056,9 +1056,9 @@ export const make = Effect.gen(function* () { } const settings = yield* desktopSettings.get; - const enabled = yield* shouldEnableAutoUpdates; - yield* setState(yield* baseUpdateState(settings.updateChannel, enabled)); - if (!enabled) { + const disabledReason = yield* resolveDisabledReason; + yield* setState(yield* baseUpdateState(settings.updateChannel, disabledReason)); + if (Option.isSome(disabledReason)) { return; } yield* Ref.set(updaterConfiguredRef, true); @@ -1127,10 +1127,10 @@ export const make = Effect.gen(function* () { ), ); - const enabled = yield* shouldEnableAutoUpdates; - yield* setState(yield* baseUpdateState(nextChannel, enabled)); + const disabledReason = yield* resolveDisabledReason; + yield* setState(yield* baseUpdateState(nextChannel, disabledReason)); - if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) { + if (Option.isSome(disabledReason) || !(yield* Ref.get(updaterConfiguredRef))) { return yield* Ref.get(updateStateRef); } diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 57d185c0975e..008a83eab03d 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -80,26 +80,6 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { ]); }); - it("keeps per-version order and drops empty groups", () => { - const result = normalizeDesktopUpdateReleaseNotes( - [ - { version: "1.2.3", note: "- Newer release" }, - { version: "1.2.2", note: "Full changelog: https://example.com/compare/x...y" }, - { version: "1.2.1", note: "- Older release" }, - ], - "1.2.3", - "latest", - ); - - expect(result).toEqual({ - releaseNotes: [ - { version: "1.2.3", items: ["Newer release"], totalItems: 1 }, - { version: "1.2.1", items: ["Older release"], totalItems: 1 }, - ], - omittedReleaseCount: 0, - }); - }); - it("drops releases from other trains before grouping on nightly", () => { // electron-updater's full changelog is "every version above the running // one", and preview sorts above nightly, so the preview cuts come first. @@ -123,6 +103,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { expect(result.omittedReleaseCount).toBe(0); }); + // regression: #11753 prerelease entries leaked into a channel's update changelog it("keeps only stable releases on the latest channel", () => { const result = normalizeDesktopUpdateReleaseNotes( [ @@ -161,18 +142,6 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { expect(result.omittedReleaseCount).toBe(1); }); - it("decodes valid HTML entities", () => { - const result = normalizeDesktopUpdateReleaseNotes( - "- Fix & polish 😀", - "1.0.0", - "latest", - ); - expect(result).toEqual({ - releaseNotes: [{ version: "1.0.0", items: ["Fix & polish 😀"], totalItems: 1 }], - omittedReleaseCount: 0, - }); - }); - it("ignores malformed and empty entries instead of throwing", () => { const result = normalizeDesktopUpdateReleaseNotes( [ @@ -193,13 +162,6 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); }); - it("returns an empty result for an invalid payload", () => { - expect(normalizeDesktopUpdateReleaseNotes({ note: "- Invalid" }, "1.0.0", "latest")).toEqual({ - releaseNotes: [], - omittedReleaseCount: 0, - }); - }); - it("does not throw on out-of-range numeric entities and keeps the literal", () => { const result = normalizeDesktopUpdateReleaseNotes( "- Broken entity �", diff --git a/apps/desktop/src/updates/remoteUpdateFlow.test.ts b/apps/desktop/src/updates/remoteUpdateFlow.test.ts index d7afeafab879..5247931221f0 100644 --- a/apps/desktop/src/updates/remoteUpdateFlow.test.ts +++ b/apps/desktop/src/updates/remoteUpdateFlow.test.ts @@ -2,10 +2,8 @@ import { assert, describe, it } from "@effect/vitest"; import type { DesktopUpdateState } from "@t3tools/contracts"; import { - MAX_REMOTE_UPDATE_CHECKS, MAX_REMOTE_UPDATE_DOWNLOADS, nextRemoteDesktopUpdateStep, - normalizeRemoteUpdateReason, type RemoteDesktopUpdateAttempts, } from "./remoteUpdateFlow.ts"; @@ -117,23 +115,6 @@ describe("nextRemoteDesktopUpdateStep", () => { }); }); - it("rides along while a check or download is already in flight", () => { - assert.deepEqual( - nextRemoteDesktopUpdateStep(makeState({ status: "checking" }), NO_ATTEMPTS, null), - { - action: "wait", - }, - ); - assert.deepEqual( - nextRemoteDesktopUpdateStep( - makeState({ status: "downloading", availableVersion: "1.2.4", downloadPercent: 40 }), - NO_ATTEMPTS, - null, - ), - { action: "wait" }, - ); - }); - it("downloads an available update until the attempt cap, then fails", () => { const available = makeState({ status: "available", availableVersion: "1.2.4" }); assert.deepEqual(nextRemoteDesktopUpdateStep(available, NO_ATTEMPTS, null), { @@ -200,29 +181,4 @@ describe("nextRemoteDesktopUpdateStep", () => { reason: "The desktop app update failed.", }); }); - - it("checks from idle until the attempt cap, then fails", () => { - assert.deepEqual(nextRemoteDesktopUpdateStep(makeState(), NO_ATTEMPTS, null), { - action: "check", - }); - assert.deepEqual( - nextRemoteDesktopUpdateStep( - makeState(), - { checks: MAX_REMOTE_UPDATE_CHECKS, downloads: 0 }, - null, - ), - { - action: "done", - outcome: "failed", - reason: "The desktop app did not report an update result.", - }, - ); - }); - - it("drops blank reasons so the wire report still encodes", () => { - assert.equal(normalizeRemoteUpdateReason(undefined), undefined); - assert.equal(normalizeRemoteUpdateReason(""), undefined); - assert.equal(normalizeRemoteUpdateReason(" "), undefined); - assert.equal(normalizeRemoteUpdateReason(" feed unreachable "), "feed unreachable"); - }); }); diff --git a/apps/desktop/src/updates/remoteUpdateFlow.ts b/apps/desktop/src/updates/remoteUpdateFlow.ts index 91fb45fee3a4..f7c65b35820c 100644 --- a/apps/desktop/src/updates/remoteUpdateFlow.ts +++ b/apps/desktop/src/updates/remoteUpdateFlow.ts @@ -22,7 +22,7 @@ export interface RemoteDesktopUpdateAttempts { readonly downloads: number; } -export const MAX_REMOTE_UPDATE_CHECKS = 2; +const MAX_REMOTE_UPDATE_CHECKS = 2; export const MAX_REMOTE_UPDATE_DOWNLOADS = 3; function isInstallableDesktopUpdateState(state: DesktopUpdateState): boolean { diff --git a/apps/desktop/src/updates/updateChannels.test.ts b/apps/desktop/src/updates/updateChannels.test.ts deleted file mode 100644 index acf4155fab8e..000000000000 --- a/apps/desktop/src/updates/updateChannels.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { isNightlyDesktopVersion, resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; - -describe("updateChannels", () => { - it("keeps preview builds branded as nightly but on the latest update channel", () => { - expect(isNightlyDesktopVersion("0.0.41-preview.20260911.7")).toBe(true); - expect(resolveDefaultDesktopUpdateChannel("0.0.41-preview.20260911.7")).toBe("latest"); - expect(resolveDefaultDesktopUpdateChannel("0.0.41-nightly.20260911.7")).toBe("nightly"); - }); - - it("only matches the first prerelease identifier", () => { - expect(isNightlyDesktopVersion("1.2.3-foo-preview.20260911.1")).toBe(false); - expect(isNightlyDesktopVersion("1.2.3")).toBe(false); - }); -}); diff --git a/apps/desktop/src/updates/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts index de57e0a8e2c7..41af75f61e96 100644 --- a/apps/desktop/src/updates/updateMachine.test.ts +++ b/apps/desktop/src/updates/updateMachine.test.ts @@ -6,12 +6,9 @@ import { reduceDesktopUpdateStateOnCheckStart, reduceDesktopUpdateStateOnDownloadComplete, reduceDesktopUpdateStateOnDownloadFailure, - reduceDesktopUpdateStateOnDownloadProgress, - reduceDesktopUpdateStateOnDownloadStart, reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, - resolveNotAppliedInstallVersion, } from "./updateMachine.ts"; const runtimeInfo = { @@ -21,41 +18,6 @@ const runtimeInfo = { } as const; describe("updateMachine", () => { - it("clears transient errors when a check starts", () => { - const state = reduceDesktopUpdateStateOnCheckStart( - { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), - enabled: true, - status: "error", - message: "network", - errorContext: "check", - canRetry: true, - }, - "2026-03-04T00:00:00.000Z", - ); - - expect(state.status).toBe("checking"); - expect(state.message).toBeNull(); - expect(state.errorContext).toBeNull(); - expect(state.canRetry).toBe(false); - }); - - it("records a check failure without exposing an action", () => { - const state = reduceDesktopUpdateStateOnCheckFailure( - { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), - enabled: true, - status: "checking", - }, - "network unavailable", - "2026-03-04T00:00:00.000Z", - ); - - expect(state.status).toBe("error"); - expect(state.errorContext).toBe("check"); - expect(state.canRetry).toBe(true); - }); - it("preserves an already-downloaded update while checking the feed", () => { const downloadedState = { ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), @@ -205,67 +167,4 @@ describe("updateMachine", () => { expect(state.message).toBeNull(); expect(state.errorContext).toBeNull(); }); - - it("tracks available, download start, and progress cleanly", () => { - const releaseNotes = [ - { - version: "1.1.0", - items: ["feat: add update release notes"], - totalItems: 1, - }, - ]; - const available = reduceDesktopUpdateStateOnUpdateAvailable( - { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), - enabled: true, - status: "checking", - }, - "1.1.0", - "2026-03-04T00:00:00.000Z", - releaseNotes, - 2, - ); - const downloading = reduceDesktopUpdateStateOnDownloadStart(available); - const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); - - expect(available.status).toBe("available"); - expect(available.channel).toBe("latest"); - expect(available.releaseNotes).toBe(releaseNotes); - expect(available.omittedReleaseCount).toBe(2); - expect(downloading.releaseNotes).toBe(releaseNotes); - expect(downloading.status).toBe("downloading"); - expect(downloading.downloadPercent).toBe(0); - expect(progress.downloadPercent).toBe(55.5); - expect(progress.errorContext).toBeNull(); - }); - - it("clears release notes when checking again", () => { - const state = reduceDesktopUpdateStateOnCheckStart( - { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "nightly"), - enabled: true, - status: "available", - availableVersion: "1.1.0-nightly.1", - releaseNotes: [{ version: "1.1.0-nightly.1", items: ["feat: old note"], totalItems: 1 }], - omittedReleaseCount: 2, - }, - "2026-03-04T00:00:00.000Z", - ); - - expect(state.releaseNotes).toEqual([]); - expect(state.omittedReleaseCount).toBe(0); - }); -}); - -describe("resolveNotAppliedInstallVersion", () => { - const record = { fromVersion: "1.2.3", targetVersion: "1.2.4" }; - - it("flags the target when the app came back on the version it installed from", () => { - expect(resolveNotAppliedInstallVersion(record, "1.2.3")).toBe("1.2.4"); - }); - - it("stays quiet when the install applied or the app runs another version", () => { - expect(resolveNotAppliedInstallVersion(record, "1.2.4")).toBeNull(); - expect(resolveNotAppliedInstallVersion(record, "1.2.5")).toBeNull(); - }); }); diff --git a/apps/desktop/src/window/ContextMenuImage.test.ts b/apps/desktop/src/window/ContextMenuImage.test.ts index c5e3194f5bb8..dbc7a47ee910 100644 --- a/apps/desktop/src/window/ContextMenuImage.test.ts +++ b/apps/desktop/src/window/ContextMenuImage.test.ts @@ -59,22 +59,6 @@ beforeEach(() => { ); }); -it("preserves the clipboard when the context-menu frame is missing", async () => { - await copyContextMenuImage( - contents as unknown as Electron.WebContents, - { ...params, frame: null } as unknown as Electron.ContextMenuParams, - ); - expect(contents.copyImageAt).not.toHaveBeenCalled(); - expect(native.fetch).not.toHaveBeenCalled(); - expect(native.write).not.toHaveBeenCalled(); -}); - -it("keeps native image copying for the main frame", async () => { - await copyImage({ frame: mainFrame }); - expect(contents.copyImageAt).toHaveBeenCalledWith(12, 34); - expect(native.fetch).not.toHaveBeenCalled(); -}); - it.each(["https://example.com/image.png", `data:image/png;base64,${sourcePng.toString("base64")}`])( "copies an iframe image from %s", async (srcURL) => { diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index fa3960a34ee5..df7e1fd2e85a 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -129,95 +129,6 @@ const configureMenu = ( ); describe("DesktopApplicationMenu", () => { - it.effect("keeps display branding in the macOS application menu", () => - Effect.gen(function* () { - const selectedAction = yield* Deferred.make(); - const applicationMenuTemplate = - yield* Deferred.make(); - - yield* configureMenu(selectedAction, applicationMenuTemplate, { - platform: "darwin", - appVersion: "0.0.43-nightly.20260929.2428", - }); - - const template = yield* Deferred.await(applicationMenuTemplate); - const applicationMenu = template[0]; - assert.isDefined(applicationMenu); - assert.equal(applicationMenu.label, "T3 Code (Nightly)"); - if (!Array.isArray(applicationMenu.submenu)) { - throw new Error("Expected application menu submenu to be an array."); - } - assert.equal( - applicationMenu.submenu.find((item) => item.role === "about")?.label, - "About T3 Code (Nightly)", - ); - assert.equal( - applicationMenu.submenu.find((item) => item.role === "hide")?.label, - "Hide T3 Code (Nightly)", - ); - assert.equal( - applicationMenu.submenu.find((item) => item.role === "quit")?.label, - "Quit T3 Code (Nightly)", - ); - }), - ); - - it.effect("installs the native menu and routes Settings through DesktopWindow", () => - Effect.gen(function* () { - const selectedAction = yield* Deferred.make(); - const applicationMenuTemplate = - yield* Deferred.make(); - - yield* configureMenu(selectedAction, applicationMenuTemplate); - - const template = yield* Deferred.await(applicationMenuTemplate); - const fileMenu = template.find((item) => item.label === "File"); - assert.isDefined(fileMenu); - if (!Array.isArray(fileMenu.submenu)) { - throw new Error("Expected File menu submenu to be an array."); - } - const settingsItem = fileMenu.submenu.find((item) => item.label === "Settings..."); - assert.isDefined(settingsItem); - const settingsClick = settingsItem.click; - if (typeof settingsClick !== "function") { - throw new Error("Expected Settings menu item to have a click handler."); - } - - settingsClick({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); - assert.equal(yield* Deferred.await(selectedAction), "open-settings"); - }), - ); - - it.effect("owns Paste as Text and routes it through the renderer", () => - Effect.gen(function* () { - const selectedAction = yield* Deferred.make(); - const applicationMenuTemplate = - yield* Deferred.make(); - - yield* configureMenu(selectedAction, applicationMenuTemplate); - - const template = yield* Deferred.await(applicationMenuTemplate); - const editMenu = template.find((item) => item.label === "Edit"); - assert.isDefined(editMenu); - if (!Array.isArray(editMenu.submenu)) { - throw new Error("Expected Edit menu submenu to be an array."); - } - const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text"); - assert.isDefined(pasteAsTextItem); - assert.equal(pasteAsTextItem.accelerator, "CmdOrCtrl+Shift+V"); - if (typeof pasteAsTextItem.click !== "function") { - throw new Error("Expected Paste as Text menu item to have a click handler."); - } - - pasteAsTextItem.click( - {} as Electron.MenuItem, - {} as Electron.BrowserWindow, - {} as KeyboardEvent, - ); - assert.equal(yield* Deferred.await(selectedAction), "paste-as-text"); - }), - ); - it.effect("leaves the accelerator to Chromium instead of injecting a paste", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); @@ -247,13 +158,13 @@ describe("DesktopApplicationMenu", () => { }), ); - it.effect("routes View menu zoom to the main window instead of zoom roles", () => + it.effect("registers macOS command-plus once and routes it through main-window zoom", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); const applicationMenuTemplate = yield* Deferred.make(); - yield* configureMenu(selectedAction, applicationMenuTemplate); + yield* configureMenu(selectedAction, applicationMenuTemplate, { platform: "darwin" }); const template = yield* Deferred.await(applicationMenuTemplate); const viewMenu = template.find((item) => item.label === "View"); @@ -268,9 +179,17 @@ describe("DesktopApplicationMenu", () => { ), ); - const zoomIn = viewMenu.submenu.find((item) => item.label === "Zoom In"); + const shiftedEqual = viewMenu.submenu.filter( + (item) => item.accelerator === "CmdOrCtrl+Shift+=", + ); + assert.lengthOf(shiftedEqual, 1); + assert.lengthOf( + viewMenu.submenu.filter((item) => item.accelerator === "CmdOrCtrl+numadd"), + 1, + ); + const zoomIn = shiftedEqual[0]; assert.isDefined(zoomIn); - assert.equal(zoomIn.accelerator, "CmdOrCtrl+="); + assert.isFalse(zoomIn.visible); if (typeof zoomIn.click !== "function") { throw new Error("Expected Zoom In menu item to have a click handler."); } @@ -280,37 +199,6 @@ describe("DesktopApplicationMenu", () => { }), ); - it.effect("zooms from the numeric keypad without listing the commands twice", () => - Effect.gen(function* () { - for (const [accelerator, expected] of [ - ["CmdOrCtrl+numadd", "zoom-in"], - ["CmdOrCtrl+numsub", "zoom-out"], - ["CmdOrCtrl+num0", "zoom-reset"], - ] as const) { - const selectedAction = yield* Deferred.make(); - const applicationMenuTemplate = - yield* Deferred.make(); - - yield* configureMenu(selectedAction, applicationMenuTemplate); - - const template = yield* Deferred.await(applicationMenuTemplate); - const viewMenu = template.find((item) => item.label === "View"); - if (!viewMenu || !Array.isArray(viewMenu.submenu)) { - throw new Error("Expected View menu submenu to be an array."); - } - const item = viewMenu.submenu.find((entry) => entry.accelerator === accelerator); - assert.isDefined(item); - assert.isFalse(item.visible); - if (typeof item.click !== "function") { - throw new Error(`Expected ${accelerator} menu item to have a click handler.`); - } - - item.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); - assert.equal(yield* Deferred.await(selectedAction), expected); - } - }), - ); - it.effect("reloads the main window even while a browser page has focus", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index c51e13e58673..aeaca8350709 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -263,6 +263,16 @@ export const make = Effect.gen(function* () { visible: false, click: zoomClick("in"), }, + ...(environment.platform === "darwin" + ? [ + { + label: "Zoom In", + accelerator: "CmdOrCtrl+Shift+=", + visible: false, + click: zoomClick("in"), + }, + ] + : []), { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") }, // The numeric keypad sends its own key codes, so the number-row // accelerators above never match it. diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index cebdcbb77972..dbdde033cae7 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -3,18 +3,16 @@ import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import { DesktopSnapShotId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; -import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; -import * as References from "effect/References"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import * as Electron from "electron"; -import * as NodeEvents from "node:events"; import { vi } from "vite-plus/test"; vi.mock("electron", async (importOriginal) => ({ @@ -47,12 +45,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { - MENU_ACTION_CHANNEL, - SNAP_SHOT_EVENT_CHANNEL, - TRACKPAD_SCROLL_END_CHANNEL, - WINDOW_FULLSCREEN_STATE_CHANNEL, -} from "../ipc/channels.ts"; +import { SNAP_SHOT_EVENT_CHANNEL } from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; import * as PreviewManager from "../preview/Manager.ts"; @@ -227,6 +220,9 @@ function layerTest(input: { readonly beforeMainWindowBoundsUpdate?: ( bounds: DesktopAppSettings.DesktopWindowBounds, ) => Effect.Effect; + readonly setPreviewMainWindow?: (window: Electron.BrowserWindow) => Effect.Effect; + readonly destroyAll?: Effect.Effect; + readonly onWindowCreate?: Effect.Effect; readonly openedExternalUrls?: unknown[]; readonly copiedTexts?: string[]; readonly onPopupTemplate?: (input: ElectronMenu.ElectronMenuTemplateInput) => Effect.Effect; @@ -275,6 +271,7 @@ function layerTest(input: { input.createdWindowOptions?.push(options); }).pipe( Effect.andThen(Ref.update(input.createCount, (count) => count + 1)), + Effect.andThen(input.onWindowCreate ?? Effect.void), Effect.as(input.window), ), main: Ref.get(input.mainWindow), @@ -288,7 +285,7 @@ function layerTest(input: { prepareReveal: () => Effect.succeed(false), reveal: (window) => Effect.sync(() => input.onReveal?.(window)), sendAll: () => Effect.void, - destroyAll: Effect.void, + destroyAll: input.destroyAll ?? Effect.void, syncAllAppearance: (sync) => sync(input.window), } satisfies ElectronWindow.ElectronWindow["Service"]); @@ -333,7 +330,7 @@ function layerTest(input: { }), Layer.mock(PreviewManager.PreviewManager)({ getBrowserSession: () => Effect.succeed({} as Electron.Session), - setMainWindow: () => Effect.void, + setMainWindow: input.setPreviewMainWindow ?? (() => Effect.void), prepareWebview: () => Effect.void, isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), @@ -344,6 +341,7 @@ function layerTest(input: { }), ), ), + Layer.merge(DesktopState.layer), ); } @@ -425,6 +423,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n DesktopAppSettings.layerTest(), layerDesktopClientSettings, layerDesktopServerExposure, + DesktopState.layer, layerElectronApp, layerElectronMenu, Layer.succeed(ElectronShell.ElectronShell, { @@ -456,143 +455,6 @@ const captureOne = DesktopSnapShotId.make("11111111-1111-4111-8111-111111111111" const captureTwo = DesktopSnapShotId.make("22222222-2222-4222-8222-222222222222"); describe("DesktopWindow", () => { - it.effect("shows native context menus for browser guests and sign-in popups", () => - Effect.gen(function* () { - const host = makeFakeBrowserWindow(); - const popup = makeFakeBrowserWindow(); - let focusedContents: unknown = host.window.webContents; - const makeContents = () => { - const contents = Object.assign(new NodeEvents.EventEmitter(), { - mainFrame: { - routingId: 7, - isDestroyed: vi.fn(() => false), - } as unknown as Electron.WebFrameMain, - isDestroyed: vi.fn(() => false), - focus: vi.fn(() => { - focusedContents = contents; - }), - copyImageAt: vi.fn(), - replaceMisspelling: vi.fn(), - }); - return contents; - }; - const guest = makeContents(); - const popupContents = makeContents(); - const popupWindow = { ...popup.window, webContents: popupContents }; - const menus = yield* Queue.unbounded<{ - input: ElectronMenu.ElectronMenuTemplateInput; - focusedContents: unknown; - }>(); - const copiedTexts: string[] = []; - const layer = layerTest({ - window: host.window, - createCount: yield* Ref.make(0), - mainWindow: yield* Ref.make>(Option.none()), - copiedTexts, - onPopupTemplate: (input) => - Queue.offer(menus, { input, focusedContents }).pipe(Effect.asVoid), - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - const attach = host.webContentsListeners.get("did-attach-webview"); - assert.isDefined(attach); - attach({}, guest); - attach({}, guest); - guest.emit("did-create-window", popupWindow); - guest.emit("did-create-window", popupWindow); - - for (const [contents, owner] of [ - [guest, host.window], - [popupContents, popupWindow], - ] as const) { - const frame = { routingId: 7 } as Electron.WebFrameMain; - const preventDefault = vi.fn(); - const params = { - frame, - x: 12, - y: 34, - misspelledWord: "helo", - dictionarySuggestions: ["hello"], - linkURL: "", - mediaType: "none", - editFlags: { canCut: false, canCopy: true, canPaste: true, canSelectAll: true }, - }; - focusedContents = host.window.webContents; - contents.emit("context-menu", { preventDefault }, params); - const menu = yield* Queue.take(menus); - assert.strictEqual(menu.input.window, owner); - assert.strictEqual(menu.input.frame, frame); - assert.strictEqual(menu.focusedContents, contents); - assert.equal(preventDefault.mock.calls.length, 1); - assert.deepEqual( - menu.input.template.filter((item) => item.role), - [ - { role: "cut", enabled: false }, - { role: "copy", enabled: true }, - { role: "paste", enabled: true }, - { role: "selectAll", enabled: true }, - ], - ); - const correction = menu.input.template.find((item) => item.label === "hello"); - assert.isDefined(correction?.click); - correction.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent); - assert.deepEqual(contents.replaceMisspelling.mock.calls, [["hello"]]); - - contents.emit( - "context-menu", - { preventDefault }, - { - ...params, - frame: contents.mainFrame, - misspelledWord: "", - dictionarySuggestions: [], - mediaType: "image", - linkURL: "https://example.com/image.png", - }, - ); - const imageMenu = (yield* Queue.take(menus)).input; - assert.strictEqual(imageMenu.frame, contents.mainFrame); - const copyImage = imageMenu.template.find((item) => item.label === "Copy Image"); - const copyLink = imageMenu.template.find((item) => item.label === "Copy Link"); - assert.isDefined(copyImage?.click); - assert.isDefined(copyLink?.click); - copyImage.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent); - copyLink.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent); - assert.deepEqual(contents.copyImageAt.mock.calls, [[12, 34]]); - assert.equal(copiedTexts.at(-1), "https://example.com/image.png"); - - contents.isDestroyed.mockReturnValue(true); - correction.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent); - copyImage.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent); - assert.equal(contents.replaceMisspelling.mock.calls.length, 1); - assert.equal(contents.copyImageAt.mock.calls.length, 1); - assert.equal(yield* Queue.size(menus), 0); - } - }).pipe(Effect.provide(layer)); - }), - ); - - it("leaves fullscreen before concealing a pending quit", () => { - const fakeWindow = makeFakeBrowserWindow(); - - DesktopWindow.concealPendingQuitWindow(fakeWindow.window); - assert.deepEqual(fakeWindow.setOpacity.mock.calls, [[0]]); - - fakeWindow.setOpacity.mockClear(); - fakeWindow.isFullScreen.mockReturnValue(true); - DesktopWindow.concealPendingQuitWindow(fakeWindow.window); - assert.deepEqual(fakeWindow.setFullScreen.mock.calls, [[false]]); - assert.deepEqual(fakeWindow.setOpacity.mock.calls, [[0]]); - - fakeWindow.setOpacity.mockClear(); - fakeWindow.isFullScreen.mockReturnValue(false); - fakeWindow.isDestroyed.mockReturnValue(true); - DesktopWindow.concealPendingQuitWindow(fakeWindow.window); - assert.equal(fakeWindow.setOpacity.mock.calls.length, 0); - }); - it("restores bounds only when the window fits within a connected display", () => { const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; const displays = [ @@ -610,60 +472,6 @@ describe("DesktopWindow", () => { ); }); - it("recognizes only same-origin renderer navigations", () => { - assert.isTrue( - DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "t3code://app/", - navigationUrl: "t3code://app/settings/connections", - }), - ); - assert.isFalse( - DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "t3code://app/", - navigationUrl: "https://accounts.microsoft.com/oauth", - }), - ); - assert.isFalse( - DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "t3code://app/", - navigationUrl: "not a url", - }), - ); - }); - - it.effect("does not open a development window until the backend is ready", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - createdWindowOptions, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.activate; - assert.equal(yield* Ref.get(createCount), 0); - - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - assert.equal(yield* Ref.get(createCount), 1); - assert.equal(createdWindowOptions[0]?.width, 1100); - assert.equal(createdWindowOptions[0]?.height, 780); - assert.isUndefined(createdWindowOptions[0]?.x); - assert.isUndefined(createdWindowOptions[0]?.y); - assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); - assert.isFalse(createdWindowOptions[0]?.webPreferences?.backgroundThrottling); - assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); - assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); - assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); - }).pipe(Effect.provide(layer)); - }), - ); - it.effect( "opens and reopens the window without backend readiness when local execution is disabled", () => @@ -740,36 +548,13 @@ describe("DesktopWindow", () => { }), ); - it.effect("forwards native trackpad release to the renderer", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const send = vi.spyOn(fakeWindow.window.webContents, "send"); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - const onInput = fakeWindow.webContentsListeners.get("input-event"); - if (!onInput) return yield* Effect.die("input-event listener was not registered"); - onInput({}, { type: "gestureScrollUpdate" }); - assert.notInclude( - send.mock.calls.map(([channel]) => channel), - TRACKPAD_SCROLL_END_CHANNEL, - ); - onInput({}, { type: "gestureScrollEnd" }); - assert.isTrue(send.mock.calls.some(([channel]) => channel === TRACKPAD_SCROLL_END_CHANNEL)); - }).pipe(Effect.provide(layer)); - }), - ); - // Chromium hands the main window's zoom level down to embedded preview // guests, so every app zoom has to put the preview browser back at its own // zoom or zooming the UI drags the previewed page with it. - it.effect("restores the preview browser's own zoom after zooming the app", () => + it.effect("zooms main with a focused popup and reapplies preview zoom", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); + const popup = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); const previewZoomReapplies: number[] = []; @@ -778,6 +563,7 @@ describe("DesktopWindow", () => { createCount, mainWindow, previewZoomReapplies, + focusedWindow: popup.window, }); yield* Effect.gen(function* () { @@ -794,6 +580,7 @@ describe("DesktopWindow", () => { [-0.5, -1, -0.5, 0], ); assert.deepEqual(previewZoomReapplies, [-0.5, -1, -0.5, 0]); + assert.equal(popup.setZoomLevel.mock.calls.length, 0); }).pipe(Effect.provide(layer)); }), ); @@ -863,161 +650,138 @@ describe("DesktopWindow", () => { }), ); - it.effect("keeps macOS window buttons centered when zooming and leaving fullscreen", () => + it.effect("does not open main or splash windows once quitting has started", () => Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); + const fake = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow }); - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - for (const direction of ["in", "in", "out", "reset", "out"] as const) { - yield* desktopWindow.zoomMain(direction); - const position = fakeWindow.setWindowButtonPosition.mock.lastCall?.[0]; - assert.isDefined(position); - const headerCenter = 26 * fakeWindow.window.webContents.getZoomFactor(); - assert.isAtMost(Math.abs(position.y + 7 - headerCenter), 0.5); - assert.equal(position.x, 16); + const windows = yield* DesktopWindow.DesktopWindow; + const state = yield* DesktopState.DesktopState; + yield* Ref.set(state.quitting, true); + + yield* windows.showConnectingSplash; + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + yield* windows.activate; + yield* windows.dispatchMenuAction("open-settings"); + for (const creation of [ + windows.createMain, + windows.ensureMain, + windows.revealOrCreateMain, + ]) { + assert.isTrue(Exit.isFailure(yield* Effect.exit(creation))); } - fakeWindow.isFullScreen.mockReturnValue(true); - fakeWindow.setWindowButtonPosition.mockClear(); - yield* desktopWindow.zoomMain("reset"); - assert.equal(fakeWindow.setWindowButtonPosition.mock.calls.length, 0); - - fakeWindow.isFullScreen.mockReturnValue(false); - fakeWindow.windowListeners.get("leave-full-screen")?.(); - assert.deepEqual(fakeWindow.setWindowButtonPosition.mock.lastCall, [{ x: 16, y: 19 }]); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect("uses the persisted main window bounds when opening the window", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - createdWindowOptions, - desktopSettings: { - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, - }, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - assert.equal(createdWindowOptions[0]?.width, 1320); - assert.equal(createdWindowOptions[0]?.height, 880); - assert.equal(createdWindowOptions[0]?.x, 120); - assert.equal(createdWindowOptions[0]?.y, 80); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect("restores the persisted maximized state", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - desktopSettings: { - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, - mainWindowMaximized: true, - }, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - assert.equal(fakeWindow.maximize.mock.calls.length, 0); - const readyToShow = fakeWindow.windowListeners.get("ready-to-show"); - if (!readyToShow) { - return yield* Effect.die("window ready-to-show listener was not registered"); - } - readyToShow(); - assert.equal(fakeWindow.maximize.mock.calls.length, 1); - }).pipe(Effect.provide(layer)); + assert.equal(yield* Ref.get(createCount), 0); + assert.isTrue(Option.isNone(yield* Ref.get(mainWindow))); + assert.equal(fake.send.mock.calls.length, 0); + }).pipe(Effect.provide(layerTest({ window: fake.window, createCount, mainWindow }))); }), ); - it.effect("re-enables background throttling on first reveal", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + it.effect.each(["main", "splash"] as const)( + "destroys a %s window when quit races native creation without loading or revealing it", + (kind) => + Effect.gen(function* () { + const fake = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const creationStarted = yield* Deferred.make(); + const releaseCreation = yield* Deferred.make(); + const reveal = vi.fn(); + const layer = layerTest({ + window: fake.window, + createCount, + mainWindow, + onWindowCreate: Deferred.succeed(creationStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCreation)), + ), + destroyAll: Effect.sync(() => fake.isDestroyed.mockReturnValue(true)), + onReveal: reveal, + }); - assert.equal(fakeWindow.setBackgroundThrottling.mock.calls.length, 0); - const readyToShow = fakeWindow.windowListeners.get("ready-to-show"); - if (!readyToShow) { - return yield* Effect.die("window ready-to-show listener was not registered"); - } - readyToShow(); - assert.deepEqual(fakeWindow.setBackgroundThrottling.mock.calls, [[true]]); - }).pipe(Effect.provide(layer)); - }), + yield* Effect.gen(function* () { + const windows = yield* DesktopWindow.DesktopWindow; + const state = yield* DesktopState.DesktopState; + const fiber = yield* ( + kind === "splash" + ? windows.showConnectingSplash + : windows.handleBackendReady(new URL("http://127.0.0.1:3773")) + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(creationStarted); + yield* Ref.set(state.quitting, true); + yield* Deferred.succeed(releaseCreation, undefined); + assert.isTrue(Exit.isSuccess(yield* Fiber.await(fiber))); + assert.equal(yield* Ref.get(createCount), 1); + assert.isTrue(fake.window.isDestroyed()); + assert.isTrue(Option.isNone(yield* Ref.get(mainWindow))); + assert.equal(fake.loadURL.mock.calls.length, 0); + assert.equal(fake.openDevTools.mock.calls.length, 0); + assert.equal(reveal.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), ); - it.effect("debounces move and resize bounds updates", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - mainWindowBoundsUpdates, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - const move = fakeWindow.windowListeners.get("move"); - const resize = fakeWindow.windowListeners.get("resize"); - if (!move || !resize) { - return yield* Effect.die("window bounds listeners were not registered"); - } - - fakeWindow.getBounds.mockReturnValue({ x: 120, y: 80, width: 1280, height: 840 }); - move(); - yield* TestClock.adjust(250); - - fakeWindow.getBounds.mockReturnValue({ x: 160, y: 100, width: 1360, height: 900 }); - resize(); - yield* TestClock.adjust(499); - assert.deepEqual(mainWindowBoundsUpdates, []); + it.effect.each(["backend ready", "activation", "renderer event"] as const)( + "quietly cancels %s when quit races preview cleanup", + (entry) => + Effect.gen(function* () { + const fake = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const previewStarted = yield* Deferred.make(); + const releasePreview = yield* Deferred.make(); + const reveal = vi.fn(); + const warnings: unknown[] = []; + const logger = Logger.make(({ logLevel, message }) => { + if (logLevel === "Warn" || logLevel === "Error") warnings.push(message); + }); + const layer = layerTest({ + window: fake.window, + createCount, + mainWindow, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + }, + setPreviewMainWindow: () => + Deferred.succeed(previewStarted, undefined).pipe( + Effect.andThen(Deferred.await(releasePreview)), + ), + destroyAll: Effect.sync(() => fake.isDestroyed.mockReturnValue(true)), + onReveal: reveal, + }); - yield* TestClock.adjust(1); - yield* Effect.promise(() => Promise.resolve()); - assert.deepEqual(mainWindowBoundsUpdates, [{ x: 160, y: 100, width: 1360, height: 900 }]); - }).pipe(Effect.provide(layer)); - }), + yield* Effect.gen(function* () { + const windows = yield* DesktopWindow.DesktopWindow; + const state = yield* DesktopState.DesktopState; + const creation = + entry === "backend ready" + ? windows.handleBackendReady(new URL("http://127.0.0.1:3773")) + : entry === "activation" + ? windows.activate + : windows.dispatchMenuAction("open-settings"); + const fiber = yield* creation.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(previewStarted); + yield* Ref.set(state.quitting, true); + // Cover both an already-destroyed window and one shutdown has yet to destroy. + fake.isDestroyed.mockReturnValue(entry !== "backend ready"); + yield* Deferred.succeed(releasePreview, undefined); + + assert.isTrue(Exit.isSuccess(yield* Fiber.await(fiber))); + assert.isTrue(fake.window.isDestroyed()); + assert.isTrue(Option.isNone(yield* Ref.get(mainWindow))); + assert.equal(fake.loadURL.mock.calls.length, 0); + assert.equal(fake.openDevTools.mock.calls.length, 0); + assert.equal(fake.send.mock.calls.length, 0); + assert.equal(reveal.mock.calls.length, 0); + assert.deepEqual(warnings, []); + }).pipe( + Effect.provide( + Layer.mergeAll(layer, Logger.layer([logger], { mergeWithExisting: false })), + ), + ); + }), ); it.effect("persists normal bounds and state for a maximized window", () => @@ -1057,74 +821,6 @@ describe("DesktopWindow", () => { }), ); - it.effect("persists normal bounds and state from the native maximize event", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; - const mainWindowMaximizedUpdates: boolean[] = []; - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - mainWindowBoundsUpdates, - mainWindowMaximizedUpdates, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - const maximize = fakeWindow.windowListeners.get("maximize"); - if (!maximize) { - return yield* Effect.die("window maximize listener was not registered"); - } - - fakeWindow.isMaximized.mockReturnValue(true); - fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); - fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); - maximize(); - yield* TestClock.adjust(500); - yield* Effect.promise(() => Promise.resolve()); - - assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); - assert.deepEqual(mainWindowMaximizedUpdates, [true]); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect("does not persist bounds that fail the domain schema", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - fakeWindow.getBounds.mockReturnValue({ x: 100.4, y: 80.2, width: 839.4, height: 619.4 }); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - mainWindowBoundsUpdates, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - const resize = fakeWindow.windowListeners.get("resize"); - if (!resize) { - return yield* Effect.die("window resize listener was not registered"); - } - resize(); - yield* TestClock.adjust(500); - yield* Effect.promise(() => Promise.resolve()); - - assert.deepEqual(mainWindowBoundsUpdates, []); - }).pipe(Effect.provide(layer)); - }), - ); - it.effect("preserves unrestorable bounds until the user changes the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -1165,42 +861,6 @@ describe("DesktopWindow", () => { }), ); - it.effect("flushes normal bounds when fullscreen before the debounce completes", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); - fakeWindow.getNormalBounds.mockReturnValue({ x: 200, y: 130, width: 1400, height: 940 }); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - mainWindowBoundsUpdates, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - const resize = fakeWindow.windowListeners.get("resize"); - if (!resize) { - return yield* Effect.die("window resize listener was not registered"); - } - resize(); - yield* TestClock.adjust(250); - fakeWindow.isFullScreen.mockReturnValue(true); - - yield* desktopWindow.flushMainWindowBounds; - - assert.deepEqual(mainWindowBoundsUpdates, [{ x: 200, y: 130, width: 1400, height: 940 }]); - assert.equal(fakeWindow.getBounds.mock.calls.length, 0); - assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); - }).pipe(Effect.provide(layer)); - }), - ); - it.effect("flushes normal bounds when minimized before the debounce completes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -1237,54 +897,6 @@ describe("DesktopWindow", () => { }), ); - it.effect("logs display lookup failures before falling back to the default size", () => - Effect.gen(function* () { - const displayLookupFailure = new Error("screen API unavailable"); - vi.mocked(Electron.screen.getAllDisplays).mockImplementationOnce(() => { - throw displayLookupFailure; - }); - const logRecords: Array<{ - readonly message: unknown; - readonly annotations: Readonly>; - }> = []; - const logger = Logger.make(({ fiber, message }) => { - logRecords.push({ - message, - annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, - }); - }); - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - createdWindowOptions, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - }).pipe( - Effect.provide(Layer.mergeAll(layer, Logger.layer([logger], { mergeWithExisting: false }))), - ); - - const warning = logRecords.find( - (record) => - Array.isArray(record.message) && - record.message[0] === "failed to read connected displays; using defaults", - ); - assert.isDefined(warning); - assert.strictEqual(warning.annotations.cause, displayLookupFailure); - assert.equal(createdWindowOptions[0]?.width, 1100); - assert.equal(createdWindowOptions[0]?.height, 780); - assert.isUndefined(createdWindowOptions[0]?.x); - assert.isUndefined(createdWindowOptions[0]?.y); - }), - ); - it.effect("persists the current main window bounds before the window closes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -1342,145 +954,6 @@ describe("DesktopWindow", () => { }), ); - it.effect("publishes native macOS fullscreen changes to the renderer", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - const enterFullscreen = fakeWindow.windowListeners.get("enter-full-screen"); - const leaveFullscreen = fakeWindow.windowListeners.get("leave-full-screen"); - if (!enterFullscreen || !leaveFullscreen) { - return yield* Effect.die("fullscreen listeners were not registered"); - } - - enterFullscreen(); - leaveFullscreen(); - assert.deepEqual(fakeWindow.send.mock.calls, [ - [WINDOW_FULLSCREEN_STATE_CHANNEL, true], - [WINDOW_FULLSCREEN_STATE_CHANNEL, false], - ]); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect( - "recovers killed renderers without reloading indefinitely or reviving closed windows", - () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow }); - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.createMain; - const onGone = fakeWindow.webContentsListeners.get("render-process-gone"); - const onLoaded = fakeWindow.webContentsListeners.get("did-finish-load"); - if (!onGone || !onLoaded) - return yield* Effect.die("renderer lifecycle listeners missing"); - - onGone({}, { reason: "clean-exit", exitCode: 0 }); - yield* TestClock.adjust(500); - assert.equal(fakeWindow.loadURL.mock.calls.length, 1); - - for (let attempt = 0; attempt < 4; attempt += 1) { - onGone({}, { reason: "killed", exitCode: 9 }); - yield* TestClock.adjust(500); - onLoaded(); - } - assert.equal(fakeWindow.loadURL.mock.calls.length, 4); - - yield* TestClock.adjust(60_000); - onGone({}, { reason: "killed", exitCode: 9 }); - yield* TestClock.adjust(500); - assert.equal(fakeWindow.loadURL.mock.calls.length, 5); - assert.equal(yield* Ref.get(createCount), 1); - - onGone({}, { reason: "killed", exitCode: 9 }); - fakeWindow.isDestroyed.mockReturnValue(true); - yield* TestClock.adjust(500); - assert.equal(fakeWindow.loadURL.mock.calls.length, 5); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect("recovers when the development renderer is temporarily unreachable", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - const didFailLoad = fakeWindow.webContentsListeners.get("did-fail-load"); - const didFinishLoad = fakeWindow.webContentsListeners.get("did-finish-load"); - if (!didFailLoad || !didFinishLoad) { - return yield* Effect.die("renderer load listeners were not registered"); - } - - didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true); - assert.equal(fakeWindow.loadURL.mock.calls.length, 1); - - yield* TestClock.adjust(100); - assert.deepEqual(fakeWindow.loadURL.mock.calls, [ - ["t3code-dev://app/"], - ["t3code-dev://app/"], - ]); - assert.equal(fakeWindow.reload.mock.calls.length, 0); - - didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true); - didFinishLoad(); - yield* TestClock.adjust(250); - assert.equal(fakeWindow.loadURL.mock.calls.length, 2); - assert.equal(fakeWindow.reload.mock.calls.length, 0); - }).pipe(Effect.provide(layer)); - }), - ); - - it("retries only transient failures for the development renderer", () => { - assert.isTrue( - DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ - applicationUrl: "t3code-dev://app/", - errorCode: -102, - isMainFrame: true, - validatedUrl: "t3code-dev://app/", - }), - ); - assert.isFalse( - DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ - applicationUrl: "t3code-dev://app/", - errorCode: -3, - isMainFrame: true, - validatedUrl: "t3code-dev://app/", - }), - ); - assert.isFalse( - DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ - applicationUrl: "t3code-dev://app/", - errorCode: -102, - isMainFrame: true, - validatedUrl: "https://example.com/", - }), - ); - }); - it.effect("opens safe off-origin renderer navigations in the system browser", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -1549,68 +1022,6 @@ describe("DesktopWindow", () => { }), ); - it.effect( - "re-reveals the connecting splash on activate while the backend is still cold-booting", - () => - Effect.gen(function* () { - const splash = makeFakeBrowserWindow(); - const scenario = yield* makeSplashScenario([splash.window]); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - - yield* desktopWindow.showConnectingSplash; - assert.equal(yield* Ref.get(scenario.createCalls), 1); - - yield* desktopWindow.activate; - assert.equal(yield* Ref.get(scenario.createCalls), 1); - assert.deepEqual(yield* Ref.get(scenario.revealedWindows), [splash.window]); - }).pipe(Effect.provide(scenario.layer)); - }), - ); - - it.effect("does not dispatch menu actions to the splash before the backend is ready", () => - Effect.gen(function* () { - const splash = makeFakeBrowserWindow(); - const main = makeFakeBrowserWindow(); - const scenario = yield* makeSplashScenario([splash.window, main.window]); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - - yield* desktopWindow.showConnectingSplash; - yield* desktopWindow.dispatchMenuAction("open-settings"); - - assert.equal(yield* Ref.get(scenario.createCalls), 1); - assert.equal(splash.send.mock.calls.length, 0); - assert.equal(main.send.mock.calls.length, 0); - }).pipe(Effect.provide(scenario.layer)); - }), - ); - - it.effect("dispatches menu actions after backend readiness when no main window exists", () => - Effect.gen(function* () { - const splash = makeFakeBrowserWindow(); - const main = makeFakeBrowserWindow(); - const scenario = yield* makeSplashScenario([splash.window, null, main.window]); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - - yield* desktopWindow.showConnectingSplash; - const readyExit = yield* Effect.exit( - desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")), - ); - assert.equal(readyExit._tag, "Failure"); - - yield* desktopWindow.dispatchMenuAction("open-settings"); - - assert.equal(yield* Ref.get(scenario.createCalls), 3); - assert.deepEqual(main.send.mock.calls, [[MENU_ACTION_CHANNEL, "open-settings"]]); - }).pipe(Effect.provide(scenario.layer)); - }), - ); - it.effect("delivers capture completion and late errors without taking focus back", () => Effect.gen(function* () { const operations: Array = []; @@ -1651,51 +1062,6 @@ describe("DesktopWindow", () => { }), ); - it.effect("delivers the renderer event even when the reveal fails", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - onReveal: () => { - throw new Error("another process kept the foreground"); - }, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - yield* Effect.exit( - desktopWindow.dispatchSnapShotEvent({ type: "started", id: captureOne }), - ); - - assert.deepEqual(fakeWindow.send.mock.calls, [ - [SNAP_SHOT_EVENT_CHANNEL, { type: "started", id: captureOne }], - ]); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect("leaves a completed capture pending while only the connecting splash exists", () => - Effect.gen(function* () { - const splash = makeFakeBrowserWindow(); - const scenario = yield* makeSplashScenario([splash.window]); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.showConnectingSplash; - yield* desktopWindow.dispatchSnapShotEvent({ type: "ready", id: captureOne }); - - assert.equal(yield* Ref.get(scenario.createCalls), 1); - assert.equal(splash.send.mock.calls.length, 0); - assert.deepEqual(yield* Ref.get(scenario.revealedWindows), []); - }).pipe(Effect.provide(scenario.layer)); - }), - ); - it.effect("does not reopen a closed main window for a completed capture", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -1717,34 +1083,4 @@ describe("DesktopWindow", () => { }).pipe(Effect.provide(layer)); }), ); - - it.effect("waits for a loading renderer without foregrounding it when capture is ready", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>( - Option.some(fakeWindow.window), - ); - const onReveal = vi.fn(); - const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow, onReveal }); - vi.mocked(fakeWindow.window.webContents.isLoadingMainFrame).mockReturnValue(true); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.dispatchSnapShotEvent({ type: "ready", id: captureOne }); - assert.equal(fakeWindow.send.mock.calls.length, 0); - const onLoad = fakeWindow.webContentsOnce.mock.calls.find( - ([event]) => event === "did-finish-load", - )?.[1]; - assert.isDefined(onLoad); - onLoad?.(); - - assert.deepEqual(fakeWindow.send.mock.calls, [ - [SNAP_SHOT_EVENT_CHANNEL, { type: "ready", id: captureOne }], - ]); - assert.equal(onReveal.mock.calls.length, 0); - assert.equal(yield* Ref.get(createCount), 0); - }).pipe(Effect.provide(layer)); - }), - ); }); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 1d76107425ed..297c67ddace0 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -13,6 +13,7 @@ import { type DesktopSnapShotEvent, DEFAULT_CLIENT_SETTINGS } from "@t3tools/con import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; @@ -69,6 +70,7 @@ type WindowTitleBarOptions = Pick< type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment + | DesktopState.DesktopState | DesktopAssets.DesktopAssets | DesktopAppSettings.DesktopAppSettings | DesktopClientSettings.DesktopClientSettings @@ -183,7 +185,7 @@ function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } -export function isSameOriginRendererNavigation(input: { +function isSameOriginRendererNavigation(input: { readonly applicationUrl: string; readonly navigationUrl: string; }): boolean { @@ -194,7 +196,7 @@ export function isSameOriginRendererNavigation(input: { } } -export function isRetryableDevelopmentRendererLoadFailure(input: { +function isRetryableDevelopmentRendererLoadFailure(input: { readonly applicationUrl: string; readonly errorCode: number; readonly isMainFrame: boolean; @@ -210,7 +212,7 @@ export function isRetryableDevelopmentRendererLoadFailure(input: { ); } -export function concealPendingQuitWindow( +function concealPendingQuitWindow( window: Pick< Electron.BrowserWindow, "isDestroyed" | "isFullScreen" | "setFullScreen" | "setOpacity" @@ -287,6 +289,7 @@ function bindFirstRevealTrigger( /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + const desktopState = yield* DesktopState.DesktopState; const assets = yield* DesktopAssets.DesktopAssets; const electronMenu = yield* ElectronMenu.ElectronMenu; const electronShell = yield* ElectronShell.ElectronShell; @@ -345,9 +348,10 @@ export const make = Effect.gen(function* () { }); const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< - Electron.BrowserWindow, + Option.Option, DesktopWindowError > { + if (yield* Ref.get(desktopState.quitting)) return Option.none(); yield* previewManager.getBrowserSession(); const applicationUrl = getDesktopUrl(environment.isDevelopment); const iconPaths = yield* assets.iconPaths; @@ -376,6 +380,7 @@ export const make = Effect.gen(function* () { if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) { yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); } + if (yield* Ref.get(desktopState.quitting)) return Option.none(); const window = yield* electronWindow.create({ ...initialBounds, minWidth: 840, @@ -404,6 +409,10 @@ export const make = Effect.gen(function* () { disableHtmlFullscreenWindowResize: true, }, }); + if (yield* Ref.get(desktopState.quitting)) { + yield* electronWindow.destroyAll; + return Option.none(); + } yield* rendererHistory.register(window.webContents, { surface: "main" }); if (environment.platform === "darwin") { @@ -497,6 +506,10 @@ export const make = Effect.gen(function* () { flushMainWindowBounds = flushBoundsPersist; yield* previewManager.setMainWindow(window); + if (yield* Ref.get(desktopState.quitting)) { + yield* electronWindow.destroyAll; + return Option.none(); + } window.webContents.on("will-attach-webview", (event, webPreferences, params) => { if ( typeof params.partition !== "string" || @@ -835,26 +848,35 @@ export const make = Effect.gen(function* () { void runPromise(electronWindow.clearMain(Option.some(window))); }); - return window; + return Option.some(window); }); const createMain = Effect.gen(function* () { const window = yield* createWindow(); - yield* electronWindow.setMain(window); - yield* logWindowInfo("main window created"); + if (Option.isSome(window)) { + yield* electronWindow.setMain(window.value); + yield* logWindowInfo("main window created"); + } return window; }).pipe(Effect.withSpan("desktop.window.createMain")); const ensureMain = Effect.gen(function* () { + if (yield* Ref.get(desktopState.quitting)) return Option.none(); const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) { - return existingWindow.value; + return existingWindow; } return yield* createMain; }).pipe(Effect.withSpan("desktop.window.ensureMain")); + const requireWindow = (window: Option.Option) => + Option.match(window, { + onNone: () => Effect.interrupt, + onSome: Effect.succeed, + }); + const revealOrCreateMain = Effect.gen(function* () { - const window = yield* ensureMain; + const window = yield* ensureMain.pipe(Effect.flatMap(requireWindow)); yield* electronWindow.reveal(window); return window; }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); @@ -865,6 +887,7 @@ export const make = Effect.gen(function* () { }); const createMainIfBackendReady = Effect.gen(function* () { + if (yield* Ref.get(desktopState.quitting)) return; if (yield* waitingForBackend) return; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) return; @@ -872,12 +895,14 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); const showConnectingSplash = Effect.gen(function* () { + if (yield* Ref.get(desktopState.quitting)) return; const existingSplash = yield* Ref.get(splashWindowRef); if (Option.isSome(existingSplash)) return; const existingWindow = yield* electronWindow.currentMainOrFirst; if (Option.isSome(existingWindow)) return; const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + if (yield* Ref.get(desktopState.quitting)) return; const splash = yield* electronWindow.create({ width: 360, height: 220, @@ -897,6 +922,10 @@ export const make = Effect.gen(function* () { sandbox: true, }, }); + if (yield* Ref.get(desktopState.quitting)) { + yield* electronWindow.destroyAll; + return; + } yield* rendererHistory.register(splash.webContents, { surface: "splash" }); yield* Ref.set(splashWindowRef, Option.some(splash)); splash.once("closed", () => { @@ -921,10 +950,12 @@ export const make = Effect.gen(function* () { payload: unknown, { reveal = true }: { readonly reveal?: boolean } = {}, ) { + if (yield* Ref.get(desktopState.quitting)) return; const existingWindow = yield* reveal ? focusedMainWindow : electronWindow.main; if (Option.isNone(existingWindow) && (!reveal || (yield* waitingForBackend))) return; - const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain; - if (targetWindow.isDestroyed()) return; + const window = Option.isSome(existingWindow) ? existingWindow : yield* ensureMain; + if (Option.isNone(window) || window.value.isDestroyed()) return; + const targetWindow = window.value; const send = Effect.sync(() => { if (!targetWindow.isDestroyed()) targetWindow.webContents.send(channel, payload); }); @@ -941,8 +972,8 @@ export const make = Effect.gen(function* () { }); return DesktopWindow.of({ - createMain, - ensureMain, + createMain: createMain.pipe(Effect.flatMap(requireWindow)), + ensureMain: ensureMain.pipe(Effect.flatMap(requireWindow)), revealOrCreateMain, prepareCaptureReveal: Effect.gen(function* () { const existingWindow = yield* currentMainWindow; @@ -951,6 +982,7 @@ export const make = Effect.gen(function* () { } }), activate: Effect.gen(function* () { + if (yield* Ref.get(desktopState.quitting)) return; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) { yield* electronWindow.reveal(existingWindow.value); @@ -994,7 +1026,7 @@ export const make = Effect.gen(function* () { }), zoomMain: Effect.fn("desktop.window.zoomMain")(function* (direction) { yield* Effect.annotateCurrentSpan({ direction }); - const window = yield* focusedMainWindow; + const window = yield* electronWindow.main; if (Option.isNone(window) || window.value.isDestroyed()) { return; } diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index 591fad185a5d..bebcd66e61c6 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -4,12 +4,10 @@ import { makeQuitShortcutHandler, QUIT_DOUBLE_PRESS_MS, QUIT_HOLD_DURATION_MS, - QUIT_HOLD_RELEASE_GRACE_MS, } from "./QuitHold.ts"; import type { QuitHoldKeyInput } from "./QuitHold.ts"; import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts"; -const HOLD_DOWN = { state: "down", mode: "hold" } as const; const DOUBLE_CLICK_DOWN = { state: "down", mode: "double-click" } as const; const UP = { state: "up" } as const; @@ -68,165 +66,6 @@ describe("makeQuitShortcutHandler", () => { vi.useRealTimers(); }); - it("shows the hint on a tap without quitting, even when the release is never seen", async () => { - // macOS suppresses the letter's keyUp while Cmd is held, so a tap may - // produce no keyUp at all. Quit must still not fire. - const harness = makeHarness(); - await harness.send(makeInput({})); - expect(harness.preventDefault).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([HOLD_DOWN]); - - vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); - expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - }); - - it("conceals a completed hold, then quits after release", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); - expect(harness.concealWindow).toHaveBeenCalledTimes(1); - expect(harness.quit).not.toHaveBeenCalled(); - await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - expect(harness.quit).not.toHaveBeenCalled(); - vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); - expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - }); - - it("keeps a concealed hold committed when another key is pressed", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(QUIT_HOLD_DURATION_MS); - - await harness.send(makeInput({ key: "Shift", shift: true })); - expect(harness.concealWindow).toHaveBeenCalledTimes(1); - expect(harness.quit).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); - expect(harness.quit).toHaveBeenCalledTimes(1); - }); - - it("keeps a concealed hold committed through a fresh Cmd+Q press", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(QUIT_HOLD_DURATION_MS); - - await harness.send(makeInput({})); - expect(harness.concealWindow).toHaveBeenCalledTimes(1); - expect(harness.quit).not.toHaveBeenCalled(); - - await harness.send(makeInput({ type: "keyUp" })); - expect(harness.quit).toHaveBeenCalledTimes(1); - }); - - it("quits when a completed hold goes quiet without release events", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS * 2); - - expect(harness.quit).not.toHaveBeenCalled(); - vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); - - expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - }); - - it("waits for slow repeats to stop before quitting", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - - vi.advanceTimersByTime(300); - await harness.send(makeInput({ isAutoRepeat: true })); - vi.advanceTimersByTime(900); - await harness.send(makeInput({ isAutoRepeat: true })); - - vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); - expect(harness.quit).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(300); - await harness.send(makeInput({ isAutoRepeat: true })); - vi.advanceTimersByTime(1_799); - expect(harness.quit).not.toHaveBeenCalled(); - vi.advanceTimersByTime(1); - expect(harness.quit).toHaveBeenCalledTimes(1); - }); - - it("uses the initial repeat delay when the first repeat completes the hold", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - - vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + 100); - await harness.send(makeInput({ isAutoRepeat: true })); - - vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); - expect(harness.quit).not.toHaveBeenCalled(); - vi.advanceTimersByTime(1_999); - expect(harness.quit).not.toHaveBeenCalled(); - vi.advanceTimersByTime(1); - expect(harness.quit).toHaveBeenCalledTimes(1); - }); - - it("waits for Q release when Cmd is released first", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); - await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - harness.preventDefault.mockClear(); - await harness.holdFor(QUIT_HOLD_RELEASE_GRACE_MS * 2, { meta: false }); - expect(harness.preventDefault).toHaveBeenCalled(); - expect(harness.quit).not.toHaveBeenCalled(); - await harness.send(makeInput({ type: "keyUp", meta: false })); - expect(harness.quit).toHaveBeenCalledTimes(1); - }); - - it("commits a concealed hold when the last Q repeat is never released", async () => { - // macOS can drop the final Q keyUp. The quit must land on its own once - // repeats stop, rather than sitting armed until an unrelated key arrives. - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); - await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - await harness.send(makeInput({ meta: false, isAutoRepeat: true })); - - vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); - expect(harness.quit).toHaveBeenCalledTimes(1); - - harness.quit.mockClear(); - await harness.send(makeInput({ key: "Meta" })); - await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS * 4); - expect(harness.quit).not.toHaveBeenCalled(); - }); - - it("does not quit when the hold stops before the duration", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(500); - await harness.send(makeInput({ type: "keyUp" })); - expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); - expect(harness.concealWindow).not.toHaveBeenCalled(); - expect(harness.quit).not.toHaveBeenCalled(); - }); - - it("cancels the hold when the modifier is released first", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); - expect(harness.quit).not.toHaveBeenCalled(); - }); - - it("quits without showing a hint in direct mode", async () => { - const harness = makeHarness({ mode: "direct" }); - await harness.send(makeInput({})); - expect(harness.concealWindow).not.toHaveBeenCalled(); - expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([]); - }); - it("honors direct mode when the key is released before its mode read settles", async () => { let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; const harness = makeHarness({ @@ -313,128 +152,6 @@ describe("makeQuitShortcutHandler", () => { expect(harness.quit).toHaveBeenCalledTimes(1); }); - it("quits on a quick double press in double-click mode when the first release is unseen", async () => { - const harness = makeHarness({ mode: "double-click" }); - await harness.send(makeInput({})); - vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); - await harness.send(makeInput({})); - expect(harness.concealWindow).not.toHaveBeenCalled(); - expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); - }); - - it("keeps the double-press hint visible after key release until the window ends", async () => { - const harness = makeHarness({ mode: "double-click" }); - await harness.send(makeInput({})); - vi.advanceTimersByTime(100); - await harness.send(makeInput({ type: "keyUp" })); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); - - vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 101); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); - vi.advanceTimersByTime(1); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); - }); - - it("accepts a second full shortcut after the modifier is released and pressed again", async () => { - const harness = makeHarness({ mode: "double-click" }); - await harness.send(makeInput({})); - await harness.send(makeInput({ type: "keyUp" })); - await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - vi.advanceTimersByTime(100); - - await harness.send(makeInput({ key: "Meta" })); - await harness.send(makeInput({})); - - expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); - }); - - it("expires a delayed double-press hint from keydown rather than mode resolution", async () => { - let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; - const harness = makeHarness({ - getMode: () => - new Promise((resolve) => { - resolveMode = resolve; - }), - }); - await harness.send(makeInput({})); - vi.advanceTimersByTime(100); - await harness.send(makeInput({ type: "keyUp" })); - vi.advanceTimersByTime(100); - resolveMode?.("double-click"); - await Promise.resolve(); - await Promise.resolve(); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); - - vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 201); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); - vi.advanceTimersByTime(1); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); - }); - - it("treats two slow presses as separate attempts in double-click mode", async () => { - const harness = makeHarness({ mode: "double-click" }); - await harness.send(makeInput({})); - await harness.send(makeInput({ type: "keyUp" })); - vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100); - await harness.send(makeInput({})); - expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); - }); - - it("quits on a quick second press in hold mode", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.send(makeInput({ type: "keyUp" })); - vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); - await harness.send(makeInput({})); - expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - }); - - it("quits on a quick second press in hold mode when the first release is unseen", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); - await harness.send(makeInput({})); - - expect(harness.concealWindow).not.toHaveBeenCalled(); - expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - }); - - it("does not count auto-repeat as a second press", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(QUIT_DOUBLE_PRESS_MS - 100); - - expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual([HOLD_DOWN]); - }); - - it("does not count a released tap after another shortcut interrupts it", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.send(makeInput({ type: "keyUp" })); - await harness.send(makeInput({ key: "c" })); - vi.advanceTimersByTime(100); - await harness.send(makeInput({})); - - expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]); - }); - - it("cancels the hold when another key interrupts it", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(500); - await harness.send(makeInput({ shift: true })); - expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - await harness.holdFor(QUIT_HOLD_DURATION_MS); - expect(harness.quit).not.toHaveBeenCalled(); - }); - it("does not count an interrupted press toward a double press", async () => { const harness = makeHarness({ mode: "double-click" }); await harness.send(makeInput({})); @@ -443,22 +160,4 @@ describe("makeQuitShortcutHandler", () => { expect(harness.quit).not.toHaveBeenCalled(); expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); }); - - it("ignores other shortcuts", async () => { - const harness = makeHarness(); - await harness.send(makeInput({ key: "w" })); - await harness.send(makeInput({ shift: true })); - await harness.send(makeInput({ meta: false })); - expect(harness.preventDefault).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual([]); - }); - - it("uses control on non-mac platforms", async () => { - const harness = makeHarness({ platform: "linux" }); - await harness.send(makeInput({ meta: false, control: true })); - expect(harness.preventDefault).toHaveBeenCalledTimes(1); - await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true }); - await harness.send(makeInput({ type: "keyUp", meta: false, control: true })); - expect(harness.quit).toHaveBeenCalledTimes(1); - }); }); diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index eb0b3141c47a..b87ae9ceab29 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -9,7 +9,7 @@ export const QUIT_DOUBLE_PRESS_MS = 500; // tap release can go completely unseen and a release-based timer would quit // anyway. Once held, quitting waits for Q keyUp or a quiet grace period after // repeats stop so they cannot reach the next app. -export const QUIT_HOLD_RELEASE_GRACE_MS = 600; +const QUIT_HOLD_RELEASE_GRACE_MS = 600; // A slow repeat rate can exceed the fixed grace. Waiting for two observed // cadences keeps the timer behind the next repeat without slowing normal rates. const QUIT_HOLD_REPEAT_CADENCE_MULTIPLIER = 2; diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index e6dcb7bd7133..8468a9538630 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -84,29 +84,6 @@ const layerNet = Layer.succeed(NetService.NetService, { } satisfies NetService.NetService["Service"]); describe("DesktopWslBackend", () => { - it.effect("does not discover or start WSL when local execution is disabled", () => - Effect.gen(function* () { - const backend = yield* DesktopWslBackend.DesktopWslBackend; - yield* backend.reconcile; - }).pipe( - Effect.provide( - DesktopWslBackend.layer.pipe( - Layer.provide(Layer.mock(DesktopBackendPool.DesktopBackendPool, {})), - Layer.provide(layerBackendConfiguration), - Layer.provide(layerServerExposure), - Layer.provide(layerNet), - Layer.provide(Layer.mock(DesktopWslEnvironment.DesktopWslEnvironment, {})), - Layer.provide( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - localEnvironmentEnabled: false, - wslBackendEnabled: true, - }), - ), - ), - ), - ), - ); it.effect("clears the stored preflight error when a registered WSL backend becomes ready", () => { let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined; let registeredWsl: DesktopBackendPool.DesktopBackendInstance | undefined; @@ -214,47 +191,4 @@ describe("DesktopWslBackend", () => { ), ); }); - - it.effect("retries an unchanged WSL instance when it is idle after failed preflight", () => { - let startCount = 0; - const primary = makeStubInstance({ - id: DesktopBackendPool.PRIMARY_INSTANCE_ID, - label: "Windows", - snapshot: primarySnapshot, - }); - const wsl = makeStubInstance({ - id: DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"), - label: "WSL (Ubuntu)", - snapshot: idleSnapshot, - start: Effect.sync(() => { - startCount += 1; - }), - }); - - return Effect.gen(function* () { - const backend = yield* DesktopWslBackend.DesktopWslBackend; - - yield* backend.reconcile; - - assert.equal(startCount, 1); - }).pipe( - Effect.provide( - DesktopWslBackend.layer.pipe( - Layer.provideMerge(DesktopBackendPool.layerTest([primary, wsl])), - Layer.provideMerge(layerBackendConfiguration), - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(layerNet), - Layer.provideMerge( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: true, - wslDistro: "Ubuntu", - wslOnly: false, - }), - ), - Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), - ), - ), - ); - }); }); diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index 95e5e84d9134..d1f61ecc0bde 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -136,7 +136,7 @@ export const layer = Layer.effect( Ref.set( preflightErrorRef, Option.some( - "Another T3 Code server is using this distro's T3 home. Stop it, or pair with it instead.", + "Another T3 Code server is using this distro's T3 home, or an older background service is set up for it. Stop that server or pair with it, or try `t3 update` in the distro. If the older launcher cannot update, run `t3 service uninstall` and optionally reinstall with `t3 service install` from a current CLI in the distro. Use `npx t3@latest` if `t3` is missing or outdated, and pass `--base-dir` with the service's T3 home to each command.", ), ), ), diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 0224eca440ea..711733cb36b5 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -2,32 +2,16 @@ import { describe, it } from "@effect/vitest"; import { afterAll, expect } from "vite-plus/test"; import * as NodeChildProcess from "node:child_process"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; -import { ChildProcessSpawner } from "effect/process"; import { buildWslRuntimeInstallScript, buildWslRuntimeInvalidateScript, buildWslRuntimePruneScript, buildWslRuntimeProbeScript, - DesktopWslDistroListError, - formatMissingToolsReason, - parseNodePath, - parseNodeVersion, parseResolvedPath, - parseToolchainReport, parseWslRuntimeRoot, - probeWslDistros, } from "./DesktopWslEnvironment.ts"; -const encoder = new TextEncoder(); - // The install script only fails the way this file cares about when a real shell // runs it, so find one that has the tools it needs: bash directly on Linux, and // the WSL distro on a Windows dev box, where Git Bash ships no flock. Anywhere @@ -94,70 +78,6 @@ const readField = (stdout: string, field: string) => { const SERVER_ENTRY_SOURCE = '#!/bin/sh\necho "t3code wsl runtime test server 0.0.0"\n'; -const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => - ChildProcessSpawner.make(() => - Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: - result.exitCode === undefined - ? Effect.never - : Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), - isRunning: Effect.succeed(result.exitCode === undefined), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.make(encoder.encode(result.stdout ?? "")), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ), - ); - -describe("probeWslDistros", () => { - it.effect("preserves a successful empty distro list", () => - Effect.gen(function* () { - const distros = yield* probeWslDistros; - expect(distros).toEqual([]); - }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - makeDistroListSpawner({ stdout: "", exitCode: 0 }), - ), - ), - ); - - it.effect("fails when the distro-list command exits unsuccessfully", () => - Effect.gen(function* () { - const error = yield* probeWslDistros.pipe(Effect.flip); - expect(error).toBeInstanceOf(DesktopWslDistroListError); - expect(error.message).toContain("exited with code 1"); - }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - makeDistroListSpawner({ exitCode: 1 }), - ), - ), - ); - - it.effect("fails when the distro-list command times out", () => { - const layer = Layer.merge( - TestClock.layer(), - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, makeDistroListSpawner({})), - ); - return Effect.gen(function* () { - const fiber = yield* probeWslDistros.pipe(Effect.flip, Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.seconds(8)); - const error = yield* Fiber.join(fiber); - expect(error).toBeInstanceOf(DesktopWslDistroListError); - expect(error.message).toContain("timed out"); - }).pipe(Effect.provide(layer)); - }); -}); - describe("WSL runtime cache", () => { it.each([ [ @@ -172,198 +92,6 @@ describe("WSL runtime cache", () => { expect(script).toContain("/1.2.3_x64__touch__tmp_nope"); expect(script).not.toContain(runtimeId); }); - - it("installs through a temporary directory and only reuses valid completed caches", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "1.2.3-x64", - "b".repeat(64), - ); - - expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"'); - expect(script).toContain(' [ -f "$ready_marker" ] &&'); - expect(script).toContain(' runtime_entry_runs "$runtime_root" &&'); - expect(script).toContain("if runtime_is_ready; then"); - expect(script).not.toContain("bin.mjs"); - expect(script).not.toContain("node-pty"); - expect(script).toContain("trap 'exit 1' HUP INT TERM"); - expect(script).toContain('exec 9> "$runtime_lock"'); - expect(script).toContain("flock -x 9"); - expect(script).not.toContain('rm -rf "$runtime_lock"'); - expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"'); - expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"'); - // The release archive wraps everything in one `t3--linux-x64/` - // directory; stripping it puts the executable at `$runtime_root/t3`. - expect(script).toContain( - "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\" --strip-components=1", - ); - expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); - expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"'); - expect(script).not.toContain('rm -rf "$runtime_root"'); - - const lockAcquired = script.indexOf("flock -x 9"); - const readinessAfterLock = script.indexOf("if runtime_is_ready; then", lockAcquired + 1); - const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); - expect(lockAcquired).toBeGreaterThan(-1); - expect(readinessAfterLock).toBeGreaterThan(lockAcquired); - expect(existingRuntimeMoved).toBeGreaterThan(readinessAfterLock); - }); - - it("verifies the archive digest before extracting, and only on a cache miss", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "1.2.3-x64", - "b".repeat(64), - ); - - const expected = "b".repeat(64); - expect(script).toContain( - "archive_sha=$(sha256sum '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' | cut -d ' ' -f 1)", - ); - expect(script).toContain(`if [ "$archive_sha" != '${expected}' ]; then`); - - const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); - const digestChecked = script.indexOf("archive_sha=$(sha256sum"); - const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); - const extracted = script.indexOf("tar -xzf"); - expect(digestChecked).toBeGreaterThan(readyShortCircuit); - expect(existingRuntimeMoved).toBeGreaterThan(digestChecked); - expect(extracted).toBeGreaterThan(digestChecked); - }); - - it("moves an in-use runtime aside instead of deleting it under a live backend", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "sha256-" + "c".repeat(64), - "b".repeat(64), - ); - - expect(script).toContain('grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null'); - expect(script).toContain(" [ -d /proc/1 ] || return 0"); - expect(script).toContain(' if runtime_in_use "$runtime_root"; then'); - - // A process's cmdline keeps the pre-rename path, so the question is only - // answerable before the move. - const inUseChecked = script.indexOf('if runtime_in_use "$runtime_root"; then'); - const moved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); - expect(inUseChecked).toBeGreaterThan(-1); - expect(inUseChecked).toBeLessThan(moved); - - const kept = script.indexOf('touch "$runtime_stale"'); - const deleted = script.indexOf('rm -rf "$runtime_stale"'); - expect(kept).toBeGreaterThan(moved); - expect(deleted).toBeGreaterThan(kept); - }); - - it("treats a runtime whose executable no longer runs as a cache miss", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "1.2.3-x64", - "b".repeat(64), - ); - - expect(script).toContain(' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1'); - - const entryCheckDefined = script.indexOf("runtime_entry_runs() {"); - const readinessDefined = script.indexOf("runtime_is_ready() {"); - const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); - expect(entryCheckDefined).toBeGreaterThan(-1); - expect(entryCheckDefined).toBeLessThan(readinessDefined); - expect(readinessDefined).toBeLessThan(readyShortCircuit); - }); - - it("re-hashes the executable against the digest the install recorded", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "1.2.3-x64", - "b".repeat(64), - ); - - expect(script).toContain(` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`); - expect(script).toContain( - ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', - ); - expect(script).toContain(' [ -n "$recorded_entry_digest" ] &&'); - expect(script).toContain( - `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/.t3code-wsl-runtime-ready"`, - ); - - const extracted = script.indexOf("tar -xzf"); - const digestRecorded = script.indexOf( - 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")', - ); - const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); - const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); - expect(digestRecorded).toBeGreaterThan(extracted); - expect(markerWritten).toBeGreaterThan(digestRecorded); - expect(promoted).toBeGreaterThan(markerWritten); - }); - - it("refuses to mark an archive whose executable does not run as ready", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "1.2.3-x64", - "b".repeat(64), - ); - - expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); - - const payloadValidated = script.indexOf('runtime_entry_runs "$runtime_tmp"'); - const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); - const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); - expect(payloadValidated).toBeGreaterThan(-1); - expect(markerWritten).toBeGreaterThan(payloadValidated); - expect(promoted).toBeGreaterThan(payloadValidated); - }); - - it("parses only absolute Linux runtime paths", () => { - expect(parseWslRuntimeRoot("runtimeRoot:/home/josh/.t3/wsl-runtime/1.2.3-x64\n")).toBe( - "/home/josh/.t3/wsl-runtime/1.2.3-x64", - ); - expect(parseWslRuntimeRoot("runtimeRoot:relative/path\n")).toBeNull(); - expect(parseWslRuntimeRoot("noise\n")).toBeNull(); - }); - - it("prunes completed runtimes except the current and newest previous cache", () => { - const script = buildWslRuntimePruneScript("1.2.3/x64"); - - expect(script).toContain('current_runtime="$runtime_parent/1.2.3_x64"'); - expect(script).toContain('[ "$candidate" -nt "$previous_runtime" ]'); - expect(script).toContain('[ "$candidate" != "$current_runtime" ] || continue'); - expect(script).toContain('[ "$candidate" != "$previous_runtime" ] || continue'); - expect(script).toContain('[ -f "$candidate/.t3code-wsl-runtime-ready" ] || continue'); - expect(script).toContain('rm -rf -- "$candidate"'); - }); - - it("never deletes a runtime another backend is running from", () => { - const script = buildWslRuntimePruneScript("1.2.3/x64"); - - expect(script).toContain(' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null'); - expect(script).toContain(' ! runtime_in_use "$candidate" || continue'); - - expect(script).toContain("[ -d /proc/1 ] || exit 0"); - - const inUseChecked = script.indexOf('! runtime_in_use "$candidate"'); - const removed = script.indexOf('rm -rf -- "$candidate"'); - expect(inUseChecked).toBeGreaterThan(-1); - expect(removed).toBeGreaterThan(inUseChecked); - }); - - it("sweeps orphaned install scratch directories the ready-marker loops cannot see", () => { - const script = buildWslRuntimePruneScript("1.2.3/x64"); - - expect(script).toContain( - 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do', - ); - expect(script).toContain('find "$scratch" -maxdepth 0 -mmin +120'); - }); - - it("invalidates a cache by dropping its ready marker, not the tree", () => { - const script = buildWslRuntimeInvalidateScript("1.2.3/x64"); - - expect(script).toContain('rm -f "$HOME/.t3/wsl-runtime/1.2.3_x64/.t3code-wsl-runtime-ready"'); - expect(script).not.toContain("rm -rf"); - }); }); describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => { @@ -374,14 +102,14 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed fixtures.length = 0; }); - const createFixture = () => { + const createFixture = (serverEntry = SERVER_ENTRY_SOURCE) => { const result = runShell( [ "set -eu", "work=$(mktemp -d)", 'stage="$work/stage/t3-0.0.0-linux-x64"', 'mkdir -p "$stage/node_modules/node-pty/build/Release" "$work/home"', - `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/t3"`, + `printf '%s' ${sh(serverEntry)} > "$stage/t3"`, 'chmod +x "$stage/t3"', `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/build/Release/pty.node"`, `tar -czf "$work/wsl-runtime.tar.gz" -C "$work/stage" t3-0.0.0-linux-x64`, @@ -459,16 +187,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(provider.stdout).toBe("linux-node-provider\n"); }); - it("keeps standalone runtime readiness independent of Node availability", () => { - const fixture = createFixture(); - expect(fixture.install().status).toBe(0); - - const probe = probeFixture(fixture); - - expect(probe.status, probe.stderr).toBe(0); - expect(parseResolvedPath(probe.stdout)).not.toBeNull(); - }); - it("keeps the inherited PATH when bash is unavailable", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); @@ -495,6 +213,18 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot); }); + it("retains the loader error when a bundled executable needs a missing system library", () => { + const fixture = createFixture( + '#!/bin/sh\nprintf "error while loading shared libraries: libatomic.so.1: cannot open shared object file\\n" >&2\nexit 127\n', + ); + + const result = fixture.install(); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("libatomic.so.1"); + expect(parseWslRuntimeRoot(result.stdout)).toBeNull(); + }); + it("reinstalls a cache whose executable was truncated", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); @@ -508,6 +238,28 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(restored.stdout).toBe(SERVER_ENTRY_SOURCE); }); + it("reinstalls an executable that fails --version despite a matching recorded digest", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const brokenEntry = "#!/bin/sh\nexit 42\n"; + const corrupt = runShell( + [ + "set -eu", + `printf '%s' ${sh(brokenEntry)} > ${sh(fixture.serverEntry)}`, + `chmod +x ${sh(fixture.serverEntry)}`, + `sha256sum ${sh(fixture.serverEntry)} | cut -d ' ' -f 1 > ${sh(`${fixture.runtimeRoot}/.t3code-wsl-runtime-ready`)}`, + ].join("\n"), + ); + expect(corrupt.status, corrupt.stderr).toBe(0); + expect(runShell(`${sh(fixture.serverEntry)} --version`).status).toBe(42); + + const repaired = fixture.install(); + expect(repaired.status, repaired.stderr).toBe(0); + expect(parseWslRuntimeRoot(repaired.stdout)).toBe(fixture.runtimeRoot); + expect(runShell(`cat ${sh(fixture.serverEntry)}`).stdout).toBe(SERVER_ENTRY_SOURCE); + expect(runShell(`${sh(fixture.serverEntry)} --version`).status).toBe(0); + }); + it("falls back instead of launching a corrupted cache it cannot reinstall", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); @@ -626,53 +378,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(selected.status, `${selected.stdout}\n${selected.stderr}`).toBe(0); }); - it("prunes a selected cache after its prepare-to-spawn grace period expires", () => { - const fixture = createFixture(); - expect(fixture.install().status).toBe(0); - const result = runShell( - [ - "set -eu", - `runtime_parent=${sh(fixture.runtimeParent)}`, - 'mkdir -p "$runtime_parent/sha256-current" "$runtime_parent/sha256-previous"', - 'printf ready > "$runtime_parent/sha256-current/.t3code-wsl-runtime-ready"', - 'printf ready > "$runtime_parent/sha256-previous/.t3code-wsl-runtime-ready"', - `touch -d "10 minutes ago" ${sh(fixture.runtimeRoot)}`, - `touch -d "10 minutes ago" ${sh(`${fixture.runtimeRoot}/.t3code-wsl-runtime-selected`)}`, - 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"', - `HOME=${sh(`${fixture.work}/home`)}`, - "export HOME", - buildWslRuntimePruneScript("sha256-current"), - `test ! -e ${sh(fixture.runtimeRoot)}`, - ].join("\n"), - ); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - }); - - it("removes an aged stale tree after replacing an active unready cache", () => { - const fixture = createFixture(); - expect(fixture.install().status).toBe(0); - const result = runShell( - [ - "set -eu", - `runtime_root=${sh(fixture.runtimeRoot)}`, - `runtime_parent=${sh(fixture.runtimeParent)}`, - 'rm "$runtime_root/.t3code-wsl-runtime-ready"', - holdRuntimeBusy('"$runtime_root/t3"'), - fixture.installScript(), - 'stale=$(find "$runtime_parent" -maxdepth 1 -type d -name ".sha256-*.stale.*" -print -quit)', - 'test -n "$stale"', - 'touch -d "180 minutes ago" "$stale"', - `HOME=${sh(`${fixture.work}/home`)}`, - "export HOME", - buildWslRuntimePruneScript(fixture.runtimeId), - 'test ! -e "$stale"', - ].join("\n"), - ); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - }); - it("prunes old and markerless caches without touching retained, active, locked, or unrelated roots", () => { const result = runShell( [ @@ -712,78 +417,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed }); }); -describe("parseToolchainReport", () => { - it("returns no missing tools and no node version on empty output", () => { - expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null }); - }); - - it("collects all missing: lines", () => { - const stdout = ["missing:make", "missing:g++", "nodeVersion:24.10.0"].join("\n"); - expect(parseToolchainReport(stdout)).toEqual({ - missingTools: ["make", "g++"], - nodeVersion: "24.10.0", - }); - }); - - it("ignores blank lines and trims whitespace", () => { - const stdout = [" missing:python3 ", "", " nodeVersion:v22.16.0 "].join("\n"); - expect(parseToolchainReport(stdout)).toEqual({ - missingTools: ["python3"], - nodeVersion: "v22.16.0", - }); - }); - - it("returns null node version when value after prefix is empty", () => { - expect(parseToolchainReport("nodeVersion:")).toEqual({ - missingTools: [], - nodeVersion: null, - }); - }); -}); - -describe("parseNodePath", () => { - it("extracts the absolute node path from a nodePath: line", () => { - const stdout = "nodePath:/home/josh/.nvm/versions/node/v22.16.0/bin/node"; - expect(parseNodePath(stdout)).toBe("/home/josh/.nvm/versions/node/v22.16.0/bin/node"); - }); - - it("returns null when node was not found (empty value after prefix)", () => { - expect(parseNodePath("nodePath:")).toBeNull(); - }); - - it("returns null when there is no nodePath line at all", () => { - expect(parseNodePath("missing:node\nnodeVersion:")).toBeNull(); - }); - - it("ignores surrounding noise and trims whitespace", () => { - const stdout = ["some preamble noise", " nodePath:/usr/bin/node ", "trailing"].join("\n"); - expect(parseNodePath(stdout)).toBe("/usr/bin/node"); - }); -}); - -describe("parseNodeVersion", () => { - it("extracts the node version from a nodeVersion: line", () => { - expect(parseNodeVersion("nodeVersion:24.10.0")).toBe("24.10.0"); - }); - - it("returns null when the version value is empty", () => { - expect(parseNodeVersion("nodeVersion:")).toBeNull(); - }); - - it("returns null when there is no nodeVersion line at all", () => { - expect(parseNodeVersion("nodePath:/usr/bin/node\nresolvedPath:/usr/bin")).toBeNull(); - }); - - it("ignores surrounding noise and trims whitespace", () => { - const stdout = [ - "some preamble noise", - " nodeVersion:22.16.0 ", - "nodePath:/usr/bin/node", - ].join("\n"); - expect(parseNodeVersion(stdout)).toBe("22.16.0"); - }); -}); - describe("parseResolvedPath", () => { it("preserves spaces and apostrophes in the resolved login-shell PATH", () => { const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin"; @@ -797,52 +430,4 @@ describe("parseResolvedPath", () => { "/usr/local/bin:/usr/bin", ); }); - - it("returns null when the resolved PATH is absent or empty", () => { - expect(parseResolvedPath("nodePath:/usr/bin/node\n")).toBeNull(); - expect(parseResolvedPath("resolvedPath:\n")).toBeNull(); - }); -}); - -describe("formatMissingToolsReason", () => { - it("returns null when everything is present and node is in range", () => { - expect( - formatMissingToolsReason({ missingTools: [], nodeVersion: "24.10.0" }, "^24.10"), - ).toBeNull(); - }); - - it("returns null when range is not specified and tools are present", () => { - expect(formatMissingToolsReason({ missingTools: [], nodeVersion: "18.0.0" }, null)).toBeNull(); - }); - - it("flags missing node first", () => { - const reason = formatMissingToolsReason( - { missingTools: ["node", "make"], nodeVersion: null }, - "^24.10", - ); - expect(reason).toContain("node"); - expect(reason).toContain("^24.10"); - expect(reason).toContain("make"); - expect(reason).toContain("nvm"); - }); - - it("flags an out-of-range node version with the actual version surfaced", () => { - const reason = formatMissingToolsReason( - { missingTools: [], nodeVersion: "20.0.0" }, - "^24.10 || ^22.16", - ); - expect(reason).toContain("node 20.0.0"); - expect(reason).toContain("requires ^24.10 || ^22.16"); - }); - - it("flags missing build tools without node when node is fine", () => { - const reason = formatMissingToolsReason( - { missingTools: ["g++", "python3"], nodeVersion: "24.10.0" }, - "^24.10", - ); - expect(reason).toContain("g++"); - expect(reason).toContain("python3"); - expect(reason).toContain("build-essential"); - expect(reason).not.toContain("nvm"); - }); }); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 7e00f1b40ad1..ca5b89c9d49f 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -17,7 +17,8 @@ import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts"; const PROCESS_TERMINATE_GRACE = Duration.seconds(1); const LIST_TIMEOUT = Duration.seconds(8); -const PRE_WARM_TIMEOUT = Duration.seconds(10); +// Booting a stopped distro needs more time than converting paths in a running one. +const PRE_WARM_TIMEOUT = Duration.seconds(30); const WSLPATH_TIMEOUT = Duration.seconds(10); const PROBE_TIMEOUT = Duration.seconds(10); const TOOLCHAIN_TIMEOUT = Duration.seconds(10); @@ -274,7 +275,7 @@ export const buildWslRuntimeInstallScript = ( `runtime_root="$runtime_parent/${safeRuntimeId}"`, `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`, "runtime_entry_runs() {", - ' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1', + ' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null', "}", // Hashing the entry is what tells a working cache from one whose `t3` was // swapped or half-written after install: the file is still there and may @@ -285,7 +286,7 @@ export const buildWslRuntimeInstallScript = ( "}", "runtime_is_ready() {", ' [ -f "$ready_marker" ] &&', - ' runtime_entry_runs "$runtime_root" &&', + ' runtime_entry_runs "$runtime_root" 2>/dev/null &&', ` recorded_entry_digest=$(tr -d '[:space:]' < "$ready_marker" 2>/dev/null) &&`, ' [ -n "$recorded_entry_digest" ] &&', ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', @@ -512,7 +513,7 @@ export interface ToolchainReport { readonly nodeVersion: string | null; } -export const parseToolchainReport = (stdout: string): ToolchainReport => { +const parseToolchainReport = (stdout: string): ToolchainReport => { const lines = stdout .split("\n") .map((line) => line.trim()) @@ -527,7 +528,7 @@ export const parseToolchainReport = (stdout: string): ToolchainReport => { return { missingTools, nodeVersion }; }; -export const parseNodePath = (stdout: string): string | null => { +const parseNodePath = (stdout: string): string | null => { const path = stdout .split("\n") .map((line) => line.trim()) @@ -537,7 +538,7 @@ export const parseNodePath = (stdout: string): string | null => { return path ?? null; }; -export const parseNodeVersion = (stdout: string): string | null => { +const parseNodeVersion = (stdout: string): string | null => { const version = stdout .split("\n") .map((line) => line.trim()) @@ -557,7 +558,7 @@ export const parseResolvedPath = (stdout: string): string | null => { return resolvedPath.length > 0 ? resolvedPath : null; }; -export const formatMissingToolsReason = ( +const formatMissingToolsReason = ( report: ToolchainReport, requiredRange: string | null, ): string | null => { @@ -598,141 +599,70 @@ export const formatMissingToolsReason = ( return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`; }; -const probeWslRuntimeImpl = ( +const probeWslRuntimeImpl = Effect.fn("DesktopWslEnvironment.probeWslRuntimeImpl")(function* ( distro: string | null, linuxAppRoot: string, -): Effect.Effect => - Effect.gen(function* () { - const probe = yield* runWslShell( - distro, - buildWslRuntimeProbeScript(linuxAppRoot), - PROBE_TIMEOUT, - { - resolveNode: false, - }, - ); - const transportFailureReason = formatWslShellTransportFailureReason( - probe.transportFailure, - "the staged runtime", - ); - if (transportFailureReason !== null) { - return { ok: false, reason: transportFailureReason } as const; - } - if (probe.exitCode !== 0) { - const trimmedTail = probe.stderr.trim().slice(-500); - return { - ok: false, - reason: `${linuxAppRoot}/t3 --version failed (exit ${probe.exitCode})${trimmedTail ? `: ${trimmedTail}` : ""}`, - } as const; - } - const resolvedPath = parseResolvedPath(probe.stdout); - if (resolvedPath === null) { - return { - ok: false, - reason: "WSL login-shell PATH could not be resolved during backend preflight.", - } as const; - } - return { ok: true, resolvedPath } as const; - }); +): Effect.fn.Return { + const probe = yield* runWslShell( + distro, + buildWslRuntimeProbeScript(linuxAppRoot), + PROBE_TIMEOUT, + { + resolveNode: false, + }, + ); + const transportFailureReason = formatWslShellTransportFailureReason( + probe.transportFailure, + "the staged runtime", + ); + if (transportFailureReason !== null) { + return { ok: false, reason: transportFailureReason } as const; + } + if (probe.exitCode !== 0) { + const trimmedTail = probe.stderr.trim().slice(-500); + return { + ok: false, + reason: `${linuxAppRoot}/t3 --version failed (exit ${probe.exitCode})${trimmedTail ? `: ${trimmedTail}` : ""}`, + } as const; + } + const resolvedPath = parseResolvedPath(probe.stdout); + if (resolvedPath === null) { + return { + ok: false, + reason: "WSL login-shell PATH could not be resolved during backend preflight.", + } as const; + } + return { ok: true, resolvedPath } as const; +}); -const ensureNodePtyImpl = ( +const ensureNodePtyImpl = Effect.fn("DesktopWslEnvironment.ensureNodePtyImpl")(function* ( distro: string | null, linuxRepoRoot: string, options: EnsureWslNodePtyOptions = {}, -): Effect.Effect => - Effect.gen(function* () { - // node-pty lives in the apps/server workspace's node_modules; resolve from - // there rather than the monorepo root, where Bun's hoist layout omits it. - const linuxServerDir = `${linuxRepoRoot}/apps/server`; +): Effect.fn.Return { + // node-pty lives in the apps/server workspace's node_modules; resolve from + // there rather than the monorepo root, where Bun's hoist layout omits it. + const linuxServerDir = `${linuxRepoRoot}/apps/server`; - const probe = yield* runWslShell( - distro, - NODE_PTY_PROBE_SCRIPT(linuxServerDir), - PROBE_TIMEOUT, - options, - ); - const nodePath = parseNodePath(probe.stdout); - const resolvedPath = parseResolvedPath(probe.stdout); - - const transportFailureReason = formatWslShellTransportFailureReason(probe.transportFailure); - if (transportFailureReason !== null) { - return { - ok: false, - reason: transportFailureReason, - fatal: false, - } as const; - } - - if (nodePath === null) { - const toolchainCheck = yield* runWslShell( - distro, - TOOLCHAIN_CHECK_SCRIPT, - TOOLCHAIN_TIMEOUT, - options, - ); - const toolchainTransportFailure = formatWslShellTransportFailureReason( - toolchainCheck.transportFailure, - ); - if (toolchainTransportFailure !== null) { - return { - ok: false, - reason: toolchainTransportFailure, - fatal: false, - retryLimit: TOOLCHAIN_TRANSPORT_RETRY_LIMIT, - } as const; - } - const report = parseToolchainReport(toolchainCheck.stdout); - const reason = - formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null) ?? - "Node.js was not found in the WSL distro. Install it (e.g. via nvm) and restart the desktop app."; - return { ok: false, reason, fatal: true } as const; - } - - if (resolvedPath === null) { - return { - ok: false, - reason: "WSL login-shell PATH could not be resolved during backend preflight.", - fatal: true, - } as const; - } - - if (probe.exitCode === 3) { - return { - ok: false, - reason: - 'WSL server dependencies could not be loaded (for example "node-pty"). The native packages the server needs are not unpacked where the WSL distro\'s Node can read them — this is a packaging problem with this build. Please report it.', - fatal: true, - } as const; - } - - if (probe.exitCode === 0) { - const rawVersion = parseNodeVersion(probe.stdout); - if ( - rawVersion !== null && - options.nodeEngineRange && - !satisfiesSemverRange(rawVersion, options.nodeEngineRange.trim()) - ) { - const range = options.nodeEngineRange.trim(); - return { - ok: false, - reason: `WSL Node.js ${rawVersion} does not satisfy the server's required engine range (${range}). Install a compatible version, and restart the desktop app.`, - fatal: true, - } as const; - } - return { ok: true, nodePath, resolvedPath } as const; - } + const probe = yield* runWslShell( + distro, + NODE_PTY_PROBE_SCRIPT(linuxServerDir), + PROBE_TIMEOUT, + options, + ); + const nodePath = parseNodePath(probe.stdout); + const resolvedPath = parseResolvedPath(probe.stdout); - if (options.allowBuild !== true) { - const packagedProbeFailure = formatNodePtyProbeFailureReason(probe.exitCode); - if (packagedProbeFailure !== null) { - return { - ok: false, - reason: packagedProbeFailure, - fatal: true, - } as const; - } - } + const transportFailureReason = formatWslShellTransportFailureReason(probe.transportFailure); + if (transportFailureReason !== null) { + return { + ok: false, + reason: transportFailureReason, + fatal: false, + } as const; + } + if (nodePath === null) { const toolchainCheck = yield* runWslShell( distro, TOOLCHAIN_CHECK_SCRIPT, @@ -751,55 +681,124 @@ const ensureNodePtyImpl = ( } as const; } const report = parseToolchainReport(toolchainCheck.stdout); + const reason = + formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null) ?? + "Node.js was not found in the WSL distro. Install it (e.g. via nvm) and restart the desktop app."; + return { ok: false, reason, fatal: true } as const; + } - if (options.allowBuild !== true) { - // Packaged builds ship a prebuilt Linux node-pty, so no compiler, node-gyp, - // or network is needed — and we must not nag the user to install build - // tools they don't need. - const nodeOnlyReason = formatMissingToolsReason( - { - missingTools: report.missingTools.filter((tool) => tool === "node"), - nodeVersion: report.nodeVersion, - }, - options.nodeEngineRange?.trim() || null, - ); + if (resolvedPath === null) { + return { + ok: false, + reason: "WSL login-shell PATH could not be resolved during backend preflight.", + fatal: true, + } as const; + } + + if (probe.exitCode === 3) { + return { + ok: false, + reason: + 'WSL server dependencies could not be loaded (for example "node-pty"). The native packages the server needs are not unpacked where the WSL distro\'s Node can read them — this is a packaging problem with this build. Please report it.', + fatal: true, + } as const; + } + + if (probe.exitCode === 0) { + const rawVersion = parseNodeVersion(probe.stdout); + if ( + rawVersion !== null && + options.nodeEngineRange && + !satisfiesSemverRange(rawVersion, options.nodeEngineRange.trim()) + ) { + const range = options.nodeEngineRange.trim(); return { ok: false, - reason: - nodeOnlyReason ?? - "The bundled WSL backend binary (node-pty) could not be loaded in this distro. This usually means an unsupported CPU architecture or incompatible system libraries (glibc). Use a glibc-based x64/arm64 WSL distro such as Ubuntu; if you already are, please report this with your distro and the output of `uname -m`.", + reason: `WSL Node.js ${rawVersion} does not satisfy the server's required engine range (${range}). Install a compatible version, and restart the desktop app.`, fatal: true, } as const; } + return { ok: true, nodePath, resolvedPath } as const; + } - const missingReason = formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null); - if (missingReason !== null) { - return { ok: false, reason: missingReason, fatal: true } as const; - } - - const build = yield* runWslShell( - distro, - NODE_PTY_BUILD_SCRIPT(linuxServerDir), - BUILD_TIMEOUT, - options, - ); - const buildTransportFailure = formatWslShellTransportFailureReason(build.transportFailure); - if (buildTransportFailure !== null) { + if (options.allowBuild !== true) { + const packagedProbeFailure = formatNodePtyProbeFailureReason(probe.exitCode); + if (packagedProbeFailure !== null) { return { ok: false, - reason: buildTransportFailure, - fatal: false, - retryLimit: BUILD_TRANSPORT_RETRY_LIMIT, + reason: packagedProbeFailure, + fatal: true, } as const; } - if (build.exitCode === 0) return { ok: true, nodePath, resolvedPath } as const; - const trimmedTail = `${build.stdout}${build.stderr}`.trim().slice(-500); + } + + const toolchainCheck = yield* runWslShell( + distro, + TOOLCHAIN_CHECK_SCRIPT, + TOOLCHAIN_TIMEOUT, + options, + ); + const toolchainTransportFailure = formatWslShellTransportFailureReason( + toolchainCheck.transportFailure, + ); + if (toolchainTransportFailure !== null) { + return { + ok: false, + reason: toolchainTransportFailure, + fatal: false, + retryLimit: TOOLCHAIN_TRANSPORT_RETRY_LIMIT, + } as const; + } + const report = parseToolchainReport(toolchainCheck.stdout); + + if (options.allowBuild !== true) { + // Packaged builds ship a prebuilt Linux node-pty, so no compiler, node-gyp, + // or network is needed — and we must not nag the user to install build + // tools they don't need. + const nodeOnlyReason = formatMissingToolsReason( + { + missingTools: report.missingTools.filter((tool) => tool === "node"), + nodeVersion: report.nodeVersion, + }, + options.nodeEngineRange?.trim() || null, + ); return { ok: false, - reason: `node-pty Linux build failed (exit ${build.exitCode}): ${trimmedTail || "no stderr captured"}`, + reason: + nodeOnlyReason ?? + "The bundled WSL backend binary (node-pty) could not be loaded in this distro. This usually means an unsupported CPU architecture or incompatible system libraries (glibc). Use a glibc-based x64/arm64 WSL distro such as Ubuntu; if you already are, please report this with your distro and the output of `uname -m`.", fatal: true, } as const; - }); + } + + const missingReason = formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null); + if (missingReason !== null) { + return { ok: false, reason: missingReason, fatal: true } as const; + } + + const build = yield* runWslShell( + distro, + NODE_PTY_BUILD_SCRIPT(linuxServerDir), + BUILD_TIMEOUT, + options, + ); + const buildTransportFailure = formatWslShellTransportFailureReason(build.transportFailure); + if (buildTransportFailure !== null) { + return { + ok: false, + reason: buildTransportFailure, + fatal: false, + retryLimit: BUILD_TRANSPORT_RETRY_LIMIT, + } as const; + } + if (build.exitCode === 0) return { ok: true, nodePath, resolvedPath } as const; + const trimmedTail = `${build.stdout}${build.stderr}`.trim().slice(-500); + return { + ok: false, + reason: `node-pty Linux build failed (exit ${build.exitCode}): ${trimmedTail || "no stderr captured"}`, + fatal: true, + } as const; +}); const prepareWslRuntimeImpl = Effect.fn("desktop.wsl.prepareRuntimeImpl")(function* ( distro: string | null, @@ -889,7 +888,7 @@ const invalidateWslRuntimeImpl = Effect.fn("desktop.wsl.invalidateRuntimeImpl")( }); }); -export const probeWslDistros: Effect.Effect< +const probeWslDistros: Effect.Effect< readonly WslDistro[], DesktopWslDistroListError, ChildProcessSpawner.ChildProcessSpawner @@ -1058,17 +1057,16 @@ const getUserHomeImpl = ( Effect.orElseSucceed(() => Option.none()), ); -const makeIsAvailable = ( +const makeIsAvailable = Effect.fn("DesktopWslEnvironment.makeIsAvailable")(function* ( platform: NodeJS.Platform, windir: string, -): Effect.Effect => - Effect.gen(function* () { - if (platform !== "win32") return false; - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const wslExePath = path.join(windir, "System32", "wsl.exe"); - return yield* fileSystem.exists(wslExePath).pipe(Effect.orElseSucceed(() => false)); - }); +): Effect.fn.Return { + if (platform !== "win32") return false; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const wslExePath = path.join(windir, "System32", "wsl.exe"); + return yield* fileSystem.exists(wslExePath).pipe(Effect.orElseSucceed(() => false)); +}); export interface DesktopWslEnvironmentTestStub { readonly isAvailable?: boolean; diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts index 0faa1b29472a..f267b7899c48 100644 --- a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts +++ b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts @@ -105,20 +105,6 @@ describe("DesktopWslServerTree", () => { }), ); - it.effect("returns the server root unchanged when it is a plain directory (dev)", () => - withTempDir((tempDir) => - Effect.gen(function* () { - const result = yield* ensureWith({ - baseDir: tempDir, - resourcesPath: tempDir, - isPackaged: false, - }); - assert.isTrue(result.ok); - assert.isFalse(result.ok && result.root.endsWith(".asar")); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("extracts an archive root into a version-keyed state directory", () => withTempDir((tempDir) => Effect.gen(function* () { @@ -292,32 +278,6 @@ describe("DesktopWslServerTree", () => { ).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("removes the legacy Windows extraction tree without preparing a fallback", () => - withTempDir((tempDir) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const resourcesPath = path.join(tempDir, "resources"); - const treeRoot = path.join(tempDir, "userdata", "wsl-server-tree"); - yield* fileSystem.makeDirectory(path.join(treeRoot, "1.2.3"), { recursive: true }); - yield* fileSystem.writeFileString(path.join(treeRoot, "1.2.3", "legacy"), "old"); - - yield* Effect.gen(function* () { - const tree = yield* DesktopWslServerTree.DesktopWslServerTree; - yield* tree.cleanupLegacy; - }).pipe( - Effect.provide( - DesktopWslServerTree.layer.pipe( - Layer.provideMerge(layerEnvironment({ baseDir: tempDir, resourcesPath })), - ), - ), - ); - - assert.isFalse(yield* fileSystem.exists(treeRoot)); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("re-extracts after legacy cleanup partially deletes the completed tree", () => withTempDir((tempDir) => Effect.gen(function* () { diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.ts b/apps/desktop/src/wsl/DesktopWslServerTree.ts index 37d5d78d9b60..61a461787aaf 100644 --- a/apps/desktop/src/wsl/DesktopWslServerTree.ts +++ b/apps/desktop/src/wsl/DesktopWslServerTree.ts @@ -48,22 +48,25 @@ export class DesktopWslServerTree extends Context.Service< } >()("@t3tools/desktop/wsl/DesktopWslServerTree") {} -export const forEachBoundedTree = ( +export const forEachBoundedTree = Effect.fn("DesktopWslServerTree.forEachBoundedTree")(function* < + Node, + E, + R, +>( roots: ReadonlyArray, visit: (node: Node) => Effect.Effect, E, R>, -): Effect.Effect => - Effect.gen(function* () { - const pending = [...roots]; - while (pending.length > 0) { - const batch = pending.splice(-COPY_CONCURRENCY); - const children = yield* Effect.forEach(batch, visit, { - concurrency: COPY_CONCURRENCY, - }); - for (const entries of children) { - pending.push(...entries); - } +): Effect.fn.Return { + const pending = [...roots]; + while (pending.length > 0) { + const batch = pending.splice(-COPY_CONCURRENCY); + const children = yield* Effect.forEach(batch, visit, { + concurrency: COPY_CONCURRENCY, + }); + for (const entries of children) { + pending.push(...entries); } - }); + } +}); interface CopyTreeEntry { readonly sourcePath: string; diff --git a/apps/desktop/src/wsl/wslPathParsing.test.ts b/apps/desktop/src/wsl/wslPathParsing.test.ts index dd750164b381..0a814229665d 100644 --- a/apps/desktop/src/wsl/wslPathParsing.test.ts +++ b/apps/desktop/src/wsl/wslPathParsing.test.ts @@ -30,15 +30,6 @@ describe("parseWslDistroList", () => { ]); }); - it("returns empty array for empty buffer", () => { - expect(parseWslDistroList(Buffer.alloc(0))).toEqual([]); - }); - - it("returns empty array for header-only output", () => { - const output = makeUtf16LeBuffer(" NAME STATE VERSION\r\n"); - expect(parseWslDistroList(output)).toEqual([]); - }); - it("skips malformed lines", () => { const output = makeUtf16LeBuffer( [ @@ -56,16 +47,6 @@ describe("parseWslDistroList", () => { ]); }); - it("handles output without BOM", () => { - const text = [ - " NAME STATE VERSION", - "* Ubuntu Running 2", - ].join("\r\n"); - const output = Buffer.from(text, "utf16le"); - const distros = parseWslDistroList(output); - expect(distros).toEqual([{ name: "Ubuntu", isDefault: true, version: 2 }]); - }); - it("handles UTF-8 output", () => { const text = [ " NAME STATE VERSION", @@ -82,18 +63,6 @@ describe("extractDistroFromUncPath", () => { "Ubuntu-22.04", ); }); - - it("extracts the distro from the legacy \\\\wsl$ UNC paths", () => { - expect(extractDistroFromUncPath("\\\\wsl$\\Debian\\home\\josh")).toBe("Debian"); - }); - - it("returns null for non-UNC Windows paths", () => { - expect(extractDistroFromUncPath("C:\\Users\\Josh\\project")).toBeNull(); - }); - - it("returns null when the segment is not a valid distro name", () => { - expect(extractDistroFromUncPath("\\\\wsl.localhost\\bad name!\\home")).toBeNull(); - }); }); describe("wslUncPathToLinuxPath", () => { @@ -118,12 +87,6 @@ describe("resolveWslPickFolderDefaultPath", () => { const config = { distro: null }; const distros = [{ name: "Debian", isDefault: true, version: 2 as const }]; - it("uses WSL home when no initial path is provided", () => { - expect(resolveWslPickFolderDefaultPath(undefined, config, distros)).toBe( - "\\\\wsl.localhost\\Debian\\home", - ); - }); - it("maps Linux initial paths to WSL UNC paths", () => { expect( resolveWslPickFolderDefaultPath({ initialPath: "/home/josh/project" }, config, distros), @@ -136,36 +99,14 @@ describe("resolveWslPickFolderDefaultPath", () => { ).toBe("\\\\wsl.localhost\\Debian\\home\\josh\\project"); }); - it("resolves bare ~ to the user's home dir when known", () => { - expect( - resolveWslPickFolderDefaultPath({ initialPath: "~" }, config, distros, "/home/josh"), - ).toBe("\\\\wsl.localhost\\Debian\\home\\josh"); - }); - it("falls back to /home parent when the user's home dir isn't known", () => { expect(resolveWslPickFolderDefaultPath({ initialPath: "~/project" }, config, distros)).toBe( "\\\\wsl.localhost\\Debian\\home\\project", ); }); - - it("preserves existing UNC initial paths", () => { - expect( - resolveWslPickFolderDefaultPath( - { initialPath: "\\\\wsl.localhost\\Ubuntu\\home\\josh" }, - config, - distros, - ), - ).toBe("\\\\wsl.localhost\\Ubuntu\\home\\josh"); - }); }); describe("isValidDistroName", () => { - it("accepts common distro names", () => { - for (const name of ["Ubuntu", "Ubuntu-22.04", "kali-linux", "Debian", "Ubuntu 22.04"]) { - expect(isValidDistroName(name)).toBe(true); - } - }); - it("rejects names with trailing whitespace, hyphen, or dot", () => { for (const name of ["Ubuntu ", "Ubuntu-", "Ubuntu."]) { expect(isValidDistroName(name)).toBe(false); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 7858f2236989..4404dddbaa3f 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,5 +1,5 @@ import "vite-plus/test/config"; -import { defineConfig } from "vite-plus"; +import { defaultInclude, defineConfig } from "vite-plus"; import { isDesktopRuntimeExternalDependency } from "../../scripts/lib/desktop-external-packages.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; @@ -43,6 +43,11 @@ export default defineConfig({ dependsOn: ["t3#build"], cache: false, }, + // tsc (tsc-rs) rewrites the tsbuildinfo it reads, which keeps a task out of the cache. + typecheck: { + command: "tsc --noEmit", + cache: { input: [{ auto: true }, "!*.tsbuildinfo"] }, + }, }, }, pack: [ @@ -141,9 +146,13 @@ export default defineConfig({ }, ], test: { + include: [...defaultInclude, "../../native/kde-snap-shot/src/*.test.ts"], // The Windows lane runs workspace suites concurrently; filesystem-heavy // desktop integration tests can exceed Vitest's 5 second default there. testTimeout: 15_000, - setupFiles: ["../../packages/shared/src/testing/longTempDir.ts"], + setupFiles: [ + "../../packages/shared/src/testing/longTempDir.ts", + "../../packages/shared/src/testing/providerEnv.ts", + ], }, }); diff --git a/apps/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs index 5ba3da4fba10..15a8a8f91633 100644 --- a/apps/marketing/astro.config.mjs +++ b/apps/marketing/astro.config.mjs @@ -1,5 +1,11 @@ import { defineConfig } from "astro/config"; +// Astro loads env with an empty prefix, and under `vp run` Vite+'s loadEnv then reports every +// variable to the task cache. Any env difference (TERM, VP_RAW_SUBCOMMAND, an agent's session +// vars) would rerun `astro check`. Without the runner client, loadEnv reads process.env directly; +// file tracking does not use this client. +delete process.env.VP_RUN_NODE_CLIENT_PATH; + export default defineConfig({ site: "https://t3.codes", server: { diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 6eb4a6e41f69..36dc282e89b4 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -7,8 +7,7 @@ "stage:install-scripts": "node scripts/stage-install-scripts.mjs", "dev": "node scripts/stage-install-scripts.mjs && astro dev", "build": "node scripts/stage-install-scripts.mjs && astro build", - "preview": "astro preview", - "typecheck": "astro check" + "preview": "astro preview" }, "dependencies": { "@t3tools/shared": "workspace:*", diff --git a/apps/marketing/src/lib/homeMotion.test.ts b/apps/marketing/src/lib/homeMotion.test.ts deleted file mode 100644 index 0a393b4758a0..000000000000 --- a/apps/marketing/src/lib/homeMotion.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { startHomeMotion } from "./homeMotion"; - -class ElementStub extends EventTarget { - properties = new Map(); - style = { setProperty: (name: string, value: string) => this.properties.set(name, value) }; - children: ElementStub[] = []; - querySelectorAll = () => this.children; - getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0, width: 400, height: 600 })); -} - -let observers: ObserverStub[] = []; -class ObserverStub { - constructor(private readonly callback: IntersectionObserverCallback) { - observers.push(this); - } - observe = vi.fn(); - disconnect = vi.fn(); - report(target: ElementStub, isIntersecting: boolean) { - this.callback( - [{ target, isIntersecting } as unknown as IntersectionObserverEntry], - this as unknown as IntersectionObserver, - ); - } -} - -let page = Object.assign(new EventTarget(), { visibilityState: "visible" }); -let reduced = Object.assign(new EventTarget(), { matches: false }); -let fine = Object.assign(new EventTarget(), { matches: true }); -let frames = new Map(); -let dispose: (() => void) | undefined; - -beforeEach(() => { - observers = []; - frames = new Map(); - page = Object.assign(new EventTarget(), { visibilityState: "visible" }); - reduced = Object.assign(new EventTarget(), { matches: false }); - fine = Object.assign(new EventTarget(), { matches: true }); - vi.stubGlobal("document", page); - vi.stubGlobal( - "window", - Object.assign(new EventTarget(), { - matchMedia: (query: string) => (query.includes("reduced-motion") ? reduced : fine), - }), - ); - vi.stubGlobal("IntersectionObserver", ObserverStub); - let frameId = 0; - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { - frames.set(++frameId, callback); - return frameId; - }); - vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); -}); - -afterEach(() => { - dispose?.(); - dispose = undefined; - vi.unstubAllGlobals(); -}); - -function fixture() { - const hero = new ElementStub(); - const field = new ElementStub(); - const mark = new ElementStub(); - const otherMark = new ElementStub(); - field.children = [mark, otherMark]; - const track = new ElementStub(); - const caret = new ElementStub(); - dispose = startHomeMotion({ hero, field, tracks: [track], caret } as unknown as Parameters< - typeof startHomeMotion - >[0]); - return { hero, field, mark, otherMark, track, caret, observer: observers[0]! }; -} - -function movePointer(hero: ElementStub, x = 400, y = 600) { - hero.dispatchEvent(Object.assign(new Event("pointermove"), { clientX: x, clientY: y })); -} - -describe("homepage motion", () => { - it("gates each mark, marquee track, and caret and batches pointer input into one frame", () => { - const { hero, field, mark, otherMark, track, caret, observer } = fixture(); - expect(mark.properties.get("--home-motion-state")).toBe("paused"); - expect(track.properties.get("--home-motion-state")).toBe("paused"); - observer.report(mark, true); - observer.report(track, true); - observer.report(caret, true); - expect(mark.properties.get("--home-motion-state")).toBe("running"); - expect(otherMark.properties.get("--home-motion-state")).toBe("paused"); - expect(track.properties.get("--home-motion-state")).toBe("running"); - expect(caret.properties.get("--home-motion-state")).toBe("running"); - - movePointer(hero, 100, 100); - movePointer(hero); - expect(frames.size).toBe(1); - expect(hero.getBoundingClientRect).not.toHaveBeenCalled(); - const [id, callback] = [...frames][0]!; - frames.delete(id); - callback(0); - expect(field.properties.get("--px")).toBe("18.0px"); - expect(field.properties.get("--py")).toBe("14.0px"); - - movePointer(hero); - page.visibilityState = "hidden"; - page.dispatchEvent(new Event("visibilitychange")); - expect(frames.size).toBe(0); - expect(field.properties.get("--px")).toBe("0px"); - expect(mark.properties.get("--home-motion-state")).toBe("paused"); - expect(track.properties.get("--home-motion-state")).toBe("paused"); - expect(caret.properties.get("--home-motion-state")).toBe("paused"); - page.visibilityState = "visible"; - page.dispatchEvent(new Event("visibilitychange")); - reduced.matches = true; - reduced.dispatchEvent(new Event("change")); - movePointer(hero); - expect(frames.size).toBe(0); - expect(mark.properties.get("--home-motion-state")).toBe("paused"); - reduced.matches = false; - fine.matches = false; - reduced.dispatchEvent(new Event("change")); - movePointer(hero); - expect(frames.size).toBe(0); - expect(mark.properties.get("--home-motion-state")).toBe("running"); - }); - - it("cancels pending work and ignores events after cleanup", () => { - const { hero, mark, track, observer } = fixture(); - observer.report(mark, true); - observer.report(track, true); - movePointer(hero); - dispose?.(); - observer.report(mark, true); - movePointer(hero); - reduced.dispatchEvent(new Event("change")); - expect(observer.disconnect).toHaveBeenCalledTimes(1); - expect(frames.size).toBe(0); - expect(mark.properties.get("--home-motion-state")).toBe("paused"); - expect(track.properties.get("--home-motion-state")).toBe("paused"); - }); -}); diff --git a/apps/marketing/vite.config.ts b/apps/marketing/vite.config.ts new file mode 100644 index 000000000000..f927357c438d --- /dev/null +++ b/apps/marketing/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite-plus"; + +// Astro reads astro.config.mjs. This file only configures `vp run` tasks. +export default defineConfig({ + run: { + tasks: { + // `astro check` regenerates .astro/*.d.ts and then reads them, which + // keeps a task out of the cache. Its real inputs are tracked as usual. + typecheck: { + command: "astro check", + cache: { input: [{ auto: true }, "!.astro/**"] }, + }, + }, + }, +}); diff --git a/apps/mobile/assets/context-chip-icons/circle-dashed.png b/apps/mobile/assets/context-chip-icons/circle-dashed.png new file mode 100644 index 000000000000..72985d77921d Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/circle-dashed.png differ diff --git a/apps/mobile/assets/context-chip-icons/film.png b/apps/mobile/assets/context-chip-icons/film.png new file mode 100644 index 000000000000..399752993e3a Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/film.png differ diff --git a/apps/mobile/assets/context-chip-icons/git-merge.png b/apps/mobile/assets/context-chip-icons/git-merge.png new file mode 100644 index 000000000000..06c049331e5d Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/git-merge.png differ diff --git a/apps/mobile/assets/context-chip-icons/git-pull-request-arrow.png b/apps/mobile/assets/context-chip-icons/git-pull-request-arrow.png new file mode 100644 index 000000000000..344120c4cc65 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/git-pull-request-arrow.png differ diff --git a/apps/mobile/assets/context-chip-icons/git-pull-request-closed.png b/apps/mobile/assets/context-chip-icons/git-pull-request-closed.png new file mode 100644 index 000000000000..777b09536393 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/git-pull-request-closed.png differ diff --git a/apps/mobile/assets/context-chip-icons/git-pull-request-draft.png b/apps/mobile/assets/context-chip-icons/git-pull-request-draft.png new file mode 100644 index 000000000000..b1490a873e48 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/git-pull-request-draft.png differ diff --git a/apps/mobile/assets/context-chip-icons/image.png b/apps/mobile/assets/context-chip-icons/image.png new file mode 100644 index 000000000000..ac0ef9f7b44f Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/image.png differ diff --git a/apps/mobile/assets/context-chip-icons/message-circle.png b/apps/mobile/assets/context-chip-icons/message-circle.png new file mode 100644 index 000000000000..9f409bf70c25 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/message-circle.png differ diff --git a/apps/mobile/assets/context-chip-icons/messages-square.png b/apps/mobile/assets/context-chip-icons/messages-square.png new file mode 100644 index 000000000000..73f5da793575 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/messages-square.png differ diff --git a/apps/mobile/assets/context-chip-icons/mouse-pointer-click.png b/apps/mobile/assets/context-chip-icons/mouse-pointer-click.png new file mode 100644 index 000000000000..64d8e727fb56 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/mouse-pointer-click.png differ diff --git a/apps/mobile/assets/context-chip-icons/skill.png b/apps/mobile/assets/context-chip-icons/skill.png new file mode 100644 index 000000000000..60e960d23233 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/skill.png differ diff --git a/apps/mobile/assets/context-chip-icons/terminal.png b/apps/mobile/assets/context-chip-icons/terminal.png new file mode 100644 index 000000000000..188638a9c2f7 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/terminal.png differ diff --git a/apps/mobile/deps/README.md b/apps/mobile/deps/README.md index bdb490f972a4..cf51c97c8560 100644 --- a/apps/mobile/deps/README.md +++ b/apps/mobile/deps/README.md @@ -1,3 +1,3 @@ -The Enriched Markdown tarball comes from the [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. File/skill link recognition, back navigation from horizontal blocks, table-image presses, and code-block headers use upstream implementations. The remaining fork adds clipboard metadata, parsed asset manifests, signed image source resolution, React media slots, and development-host autolinks. +The Enriched Markdown tarball comes from [`feat/t3chat-markdown-parity-v2`](https://github.com/pingdotgg/enriched-markdown/tree/feat/t3chat-markdown-parity-v2), based on Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. File/skill link recognition, back navigation from horizontal blocks, table-image presses, and code-block headers use upstream implementations. The fork retains clipboard metadata, parsed asset manifests, signed image source resolution, React media slots, and development-host autolinks. The fork retains native math parsing, scrolling and copying, table layout and gestures, and inline-code layout. Character-reference decoding, iOS intrinsic-width measurement, and HTML clipboard round trips now use the upstream fixes included in Julius's base. RaTeX uses the upstream-released 0.1.15 dependency. -To replace it, commit the fork changes, run `yarn install --immutable` and `yarn workspace react-native-enriched-markdown prepare` in the fork checkout, then `node scripts/pack-t3.mjs /path/to/t3code/apps/mobile/deps`. Bump the fork package version for each replacement, update the tarball path in `apps/mobile/package.json` and `pnpm-workspace.yaml`'s `allowBuilds`, and run `vp i` to update the lockfile. Native clients need rebuilding when this dependency changes. +To replace it, commit the fork changes, run `yarn install --immutable` and `yarn workspace react-native-enriched-markdown prepare` in the fork checkout, then `node scripts/pack-t3.mjs /path/to/t3code/apps/mobile/deps`. Keep the package version aligned with Julius's `fork/t3-mobile` branch. Rename the archive to include the first eight characters of the source commit, such as `react-native-enriched-markdown-1.2.0-t3.15-fee84114.tgz`, so each build has a distinct dependency path without version bumps. Update the tarball path in `apps/mobile/package.json` and `pnpm-workspace.yaml`'s `allowBuilds`, and run `vp i` to update the lockfile. Native clients need rebuilding when this dependency changes. diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.15-fee84114.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.15-fee84114.tgz new file mode 100644 index 000000000000..e8c91b75a781 Binary files /dev/null and b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.15-fee84114.tgz differ diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.15.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.15.tgz deleted file mode 100644 index dbf87fde65e2..000000000000 Binary files a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.15.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.27.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.27.tgz deleted file mode 100644 index 4aa9184ac209..000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.27.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.36.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.36.tgz new file mode 100644 index 000000000000..071a4022a7b3 Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.36.tgz differ diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ChipIcons.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ChipIcons.kt new file mode 100644 index 000000000000..5b466062932a --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ChipIcons.kt @@ -0,0 +1,60 @@ +package expo.modules.t3composereditor + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Handler +import android.os.Looper +import java.net.URL +import java.util.concurrent.Executors + +internal object T3ChipIcons { + private val images = HashMap() + private val pending = HashMap Unit>>() + private val downloads = Executors.newSingleThreadExecutor() + private val main = Handler(Looper.getMainLooper()) + + fun cached(uri: String): Bitmap? = images[uri] + + fun load(context: Context, uri: String, onLoaded: (String) -> Unit): Bitmap? = + images[uri] ?: when { + uri.isEmpty() -> null + uri.startsWith("http://") || uri.startsWith("https://") -> { + download(uri, onLoaded) + null + } + else -> decodeLocal(context, uri)?.also { images[uri] = it } + } + + private fun download(uri: String, onLoaded: (String) -> Unit) { + val waiting = pending[uri] + if (waiting != null) { + waiting.add(onLoaded) + return + } + pending[uri] = mutableSetOf(onLoaded) + downloads.execute { + val bitmap = runCatching { + URL(uri).openStream().use(BitmapFactory::decodeStream) + }.getOrNull() + main.post { + val callbacks = pending.remove(uri).orEmpty() + if (bitmap != null) { + images[uri] = bitmap + callbacks.forEach { it(uri) } + } + } + } + } + + private fun decodeLocal(context: Context, uri: String): Bitmap? = runCatching { + when { + uri.startsWith("/") -> BitmapFactory.decodeFile(uri) + uri.startsWith("file:") -> Uri.parse(uri).path?.let(BitmapFactory::decodeFile) + else -> context.resources.getIdentifier(uri, "drawable", context.packageName) + .takeIf { it != 0 } + ?.let { BitmapFactory.decodeResource(context.resources, it) } + } + }.getOrNull() +} diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index ca2426f8c023..0b44599016b1 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -57,10 +57,24 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( private var applyingNativeValue = false private var desiredLineHeightPx = 0 private var lastContentHeight = 0 + private var lastContentWidth = 0 private var contentInsetVertical = 0 private var tokensJson = "[]" private var tokens: List = emptyList() - private var chipTheme = ComposerChipTheme.default() + private val redrawChipsWithIcon: (String) -> Unit = { uri -> + editor.text?.let { editable -> + editable.getSpans(0, editable.length, ComposerChipSpan::class.java) + .filter { it.iconUri == uri } + .forEach { + editable.setSpan( + it, + editable.getSpanStart(it), + editable.getSpanEnd(it), + editable.getSpanFlags(it) + ) + } + } + } private var autoCorrect = true private var spellCheck = true private var nativeEventCount = 0 @@ -240,17 +254,6 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } else { applySelectionTheme(parseColor(theme.optString("selection"), editor.currentTextColor)) } - chipTheme = ComposerChipTheme( - chipBackground = parseColor(theme.optString("chipBackground"), chipTheme.chipBackground), - chipBorder = parseColor(theme.optString("chipBorder"), chipTheme.chipBorder), - chipText = parseColor(theme.optString("chipText"), chipTheme.chipText), - skillBackground = parseColor( - theme.optString("skillBackground"), - chipTheme.skillBackground, - ), - skillBorder = parseColor(theme.optString("skillBorder"), chipTheme.skillBorder), - skillText = parseColor(theme.optString("skillText"), chipTheme.skillText), - ) applyTokenSpans() } catch (_: Exception) { } @@ -424,12 +427,19 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } private fun emitContentSizeIfNeeded() { - val height = editor.layout?.height ?: editor.measuredHeight - val contentHeight = height + contentInsetVertical * 2 - if (contentHeight == lastContentHeight) return + // The laid-out text without insets. A one-line draft reports its natural width, so + // JS can tell whether it fits a narrower row than the one it is laid out in; past + // one line only the height matters. Trailing whitespace hangs past a line without + // wrapping it, so getLineMax leaves it out. + val layout = editor.layout + val contentHeight = layout?.height ?: editor.measuredHeight + val contentWidth = if (layout?.lineCount == 1) layout.getLineMax(0).toInt() else 0 + if (contentHeight == lastContentHeight && contentWidth == lastContentWidth) return lastContentHeight = contentHeight + lastContentWidth = contentWidth + val density = resources.displayMetrics.density onComposerContentSizeChange( - mapOf("height" to contentHeight / resources.displayMetrics.density), + mapOf("width" to contentWidth / density, "height" to contentHeight / density), ) } @@ -444,20 +454,18 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( if (token.start < 0 || token.end <= token.start || token.end > editable.length) return@forEach val expectedSource = editable.substring(token.start, token.end) if (expectedSource != token.source) return@forEach + T3ChipIcons.load(context, token.iconUri, redrawChipsWithIcon) editable.setSpan( ComposerChipSpan( T3ContextChip( content = T3ContextChip.Content( label = token.label, - symbol = token.symbol, - detail = token.detail - ), - fontSize = editor.textSize * 0.8f, - colors = T3ContextChip.Colors( - accent = T3ContextChip.color(token.accent, chipTheme.chipText), - foreground = chipTheme.chipText, - border = chipTheme.chipBorder + detail = token.detail, + iconUri = token.iconUri, + tintsIcon = token.tintsIcon ), + fontSize = editor.textSize, + color = T3ContextChip.color(token.accent, editor.currentTextColor), maximumWidth = ( editor.width.takeIf { it > 0 @@ -500,34 +508,17 @@ private data class ComposerToken( val label: String, val detail: String, val accent: String, - val symbol: String, + val iconUri: String, + val tintsIcon: Boolean, val start: Int, val end: Int ) -private data class ComposerChipTheme( - val chipBackground: Int, - val chipBorder: Int, - val chipText: Int, - val skillBackground: Int, - val skillBorder: Int, - val skillText: Int -) { - companion object { - fun default() = ComposerChipTheme( - chipBackground = Color.rgb(238, 240, 243), - chipBorder = Color.rgb(210, 214, 220), - chipText = Color.rgb(35, 39, 45), - skillBackground = Color.rgb(233, 239, 255), - skillBorder = Color.rgb(185, 200, 245), - skillText = Color.rgb(45, 72, 155), - ) - } -} - private class ComposerChipSpan( private val chip: T3ContextChip ) : ReplacementSpan() { + val iconUri get() = chip.iconUri + override fun getSize( paint: Paint, text: CharSequence, @@ -573,7 +564,8 @@ private fun parseTokens(value: String): List = try { label = token.optString("label"), detail = token.optString("detail"), accent = token.optString("accent"), - symbol = token.optString("symbol", "doc"), + iconUri = if (token.isNull("iconUri")) "" else token.optString("iconUri"), + tintsIcon = token.optBoolean("tintsIcon"), start = token.optInt("start"), end = token.optInt("end"), ) @@ -754,16 +746,22 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) { return shouldIntercept } - // coerceToText opens content: URIs synchronously. Leave URI-backed - // clipboard items to Android's normal paste path so the UI thread never - // reads an arbitrary provider just to measure a text paste. - private fun ClipData.plainText(): String? = - takeIf { itemCount > 0 } - ?.getItemAt(0) - ?.takeIf { it.uri == null } - ?.coerceToText(context) - ?.toString() - ?.takeIf(String::isNotEmpty) + // Match native paste's newlines, including empty items. + // URI-backed clips stay native: coerceToText can read a provider synchronously. + private fun ClipData.plainText(): String? { + var joined: StringBuilder? = null + for (index in 0 until itemCount) { + val item = getItemAt(index) + if (item.uri != null) return null + val itemText = item.coerceToText(context) ?: continue + if (joined == null) { + joined = StringBuilder(itemText) + } else { + joined.append('\n').append(itemText) + } + } + return joined?.toString()?.takeIf(String::isNotEmpty) + } override fun onKeyShortcut(keyCode: Int, event: KeyEvent): Boolean { if (keyCode == KeyEvent.KEYCODE_V && event.isCtrlPressed && event.isShiftPressed) { diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt index d4f8312b72b3..d2b7b9cb9e46 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt @@ -3,7 +3,8 @@ package expo.modules.t3composereditor import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint -import android.graphics.Path +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter import android.graphics.RectF import android.graphics.Typeface import android.text.TextPaint @@ -11,24 +12,25 @@ import android.text.TextUtils import kotlin.math.ceil import kotlin.math.min -/** Shared by editable spans and inline chat images so their metrics and colors agree. */ class T3ContextChip( content: Content, fontSize: Float, - colors: Colors, + private val color: Int, maximumWidth: Float, - private val density: Float + density: Float ) { /** What the chip says: its name, the size beside it, and the glyph that leads it. */ - data class Content(val label: String, val symbol: String, val detail: String = "") - - data class Colors(val accent: Int, val foreground: Int, val border: Int) - - private val symbol = content.symbol + data class Content( + val label: String, + val detail: String = "", + val iconUri: String = "", + val tintsIcon: Boolean = false + ) private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { textSize = fontSize typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + color = this@T3ContextChip.color } private val em = fontSize @@ -37,72 +39,47 @@ class T3ContextChip( private val detailPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { textSize = fontSize * 0.84f typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + color = this@T3ContextChip.color } private val detailText = if (content.detail.isEmpty()) "" else " ${content.detail}" private val detailWidth = if (detailText.isEmpty()) 0f else detailPaint.measureText(detailText) - // The border is stroked, and a stroke straddles the path it follows, so the box has to - // reserve a stroke on every side it insets. Reserving it once left the right edge a - // stroke short, and antialiasing shaved that curve flat against the span bounds. - private val strokeWidth = density - private val inset = strokeWidth * 2f + val iconUri = content.iconUri + private val iconPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { + if (content.tintsIcon) { + colorFilter = + PorterDuffColorFilter(this@T3ContextChip.color, PorterDuff.Mode.SRC_IN) + } + } + private val padding = density * 5 + private val iconSlot = if (iconUri.isEmpty()) 0f else em * 1.25f val width = ceil( min( maximumWidth.coerceAtLeast(em * 3), - paint.measureText(content.label) + detailWidth + em * 2.5f + inset + paint.measureText(content.label) + detailWidth + iconSlot + padding * 2 ) ) - val height = ceil(em * 1.41f + inset) + val height = ceil(paint.fontMetrics.descent - paint.fontMetrics.ascent) private val text = TextUtils.ellipsize( content.label, paint, - (width - em * 2.5f - inset - detailWidth).coerceAtLeast(0f), + (width - iconSlot - padding * 2 - detailWidth).coerceAtLeast(0f), TextUtils.TruncateAt.MIDDLE ).toString() - private val fill = Color.argb( - 28, - Color.red(colors.accent), - Color.green(colors.accent), - Color.blue(colors.accent) - ) - private val textColor = blend(colors.accent, colors.foreground, 0.22f) - private val borderColor = blend(colors.accent, colors.border, 0.34f) - - // Half a stroke keeps the border inside the box; the rest of the reserved margin is - // slack, so an antialiased edge fades out before it reaches the span bounds. - private val shape = - RectF(inset / 2f, inset / 2f, width - inset / 2f, height - inset / 2f) - private val icon = iconPath(symbol) fun draw(canvas: Canvas, x: Float, y: Float) { canvas.save() canvas.translate(x, y) - paint.style = Paint.Style.FILL - paint.color = fill - canvas.drawRoundRect(shape, em / 2, em / 2, paint) - paint.style = Paint.Style.STROKE - paint.strokeWidth = strokeWidth - paint.color = borderColor - canvas.drawRoundRect(shape, em / 2, em / 2, paint) - paint.color = textColor - canvas.save() - val iconSize = em * 1.17f - canvas.translate(em / 2 + inset / 2f, (height - iconSize) / 2) - canvas.scale(iconSize / 24, iconSize / 24) - paint.strokeWidth = 1.7f - paint.strokeJoin = Paint.Join.ROUND - paint.strokeCap = Paint.Cap.ROUND - canvas.drawPath(icon, paint) - canvas.restore() - paint.style = Paint.Style.FILL - val metrics = paint.fontMetrics - val baseline = (height - metrics.descent - metrics.ascent) / 2 - canvas.drawText(text, em * 2 + inset / 2f, baseline, paint) + T3ChipIcons.cached(iconUri)?.let { + val top = (height - em) / 2 + canvas.drawBitmap(it, null, RectF(padding, top, padding + em, top + em), iconPaint) + } + val baseline = -paint.fontMetrics.ascent + canvas.drawText(text, padding + iconSlot, baseline, paint) if (detailText.isNotEmpty()) { - detailPaint.color = paint.color canvas.drawText( detailText, - em * 2 + inset / 2f + paint.measureText(text), + padding + iconSlot + paint.measureText(text), baseline, detailPaint ) @@ -123,62 +100,5 @@ class T3ContextChip( } Color.parseColor(androidColor) }.getOrDefault(fallback) - - private fun blend(accent: Int, base: Int, weight: Float): Int = Color.rgb( - (Color.red(accent) * weight + Color.red(base) * (1 - weight)).toInt(), - (Color.green(accent) * weight + Color.green(base) * (1 - weight)).toInt(), - (Color.blue(accent) * weight + Color.blue(base) * (1 - weight)).toInt(), - ) - - private fun iconPath(symbol: String) = Path().apply { - fun line(vararg points: Float) { - moveTo(points[0], points[1]) - for (index in 2 until points.size step 2) lineTo(points[index], points[index + 1]) - } - when (symbol) { - "cube" -> { - line(12f, 2f, 21f, 7f, 21f, 17f, 12f, 22f, 3f, 17f, 3f, 7f, 12f, 2f) - line(3f, 7f, 12f, 12f, 21f, 7f) - line(12f, 12f, 12f, 22f) - } - // lucide `git-pull-request`, the glyph web draws: two nodes, an elbow, and a stem. - "git-pull-request" -> { - addCircle(18f, 18f, 3f, Path.Direction.CW) - addCircle(6f, 6f, 3f, Path.Direction.CW) - moveTo(13f, 6f) - lineTo(16f, 6f) - cubicTo(17.1f, 6f, 18f, 6.9f, 18f, 8f) - lineTo(18f, 15f) - line(6f, 9f, 6f, 21f) - } - "cursorarrow.click" -> { - line(4f, 3f, 19f, 12f, 12f, 14f, 9f, 21f, 4f, 3f) - line(13f, 15f, 18f, 21f) - } - "text.bubble" -> { - line(3f, 4f, 21f, 4f, 21f, 17f, 10f, 17f, 5f, 21f, 5f, 17f, 3f, 17f, 3f, 4f) - line(7f, 8f, 17f, 8f) - line(7f, 12f, 14f, 12f) - } - "terminal", "play.rectangle", "photo" -> { - addRoundRect(2f, 4f, 22f, 20f, 2f, 2f, Path.Direction.CW) - when (symbol) { - "terminal" -> { - line(6f, 8f, 10f, 12f, 6f, 16f) - line(13f, 16f, 18f, 16f) - } - "play.rectangle" -> line(9f, 8f, 16f, 12f, 9f, 16f, 9f, 8f) - else -> { - addCircle(8f, 9f, 1.5f, Path.Direction.CW) - line(3f, 18f, 11f, 12f, 15f, 15f, 18f, 12f, 21f, 16f) - } - } - } - else -> { - line(5f, 2f, 14f, 2f, 20f, 8f, 20f, 22f, 5f, 22f, 5f, 2f) - line(14f, 2f, 14f, 8f, 20f, 8f) - } - } - } } } diff --git a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt index 9838c6314680..ca15fb2b1ba1 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt @@ -1,8 +1,10 @@ package expo.modules.t3composereditor import android.content.ClipData +import android.content.ClipDescription import android.content.ClipboardManager import android.content.Context +import android.net.Uri import android.view.KeyEvent import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -91,6 +93,53 @@ class ComposerPasteTest { assertEquals("old", editor.text.toString()) } + @Test + fun regularPasteMeasuresAndInterceptsEveryTextItemJoinedWithNewlines() { + // Only the complete UTF-8 payload, including both newlines, reaches 32 KiB. + val first = "é".repeat(8191) + val last = "x".repeat(16 * 1024) + val clip = ClipData.newPlainText("test", first).apply { + addItem(ClipData.Item("")) + addItem(ClipData.Item(last)) + } + clipboard.setPrimaryClip(clip) + editor.setText("before old after") + editor.setSelection(10, 7) + val intercepted = mutableListOf>() + editor.pasteTextListener = { text, start, end -> intercepted.add(Triple(text, start, end)) } + + assertTrue(editor.onTextContextMenuItem(android.R.id.paste)) + assertEquals(listOf(Triple("$first\n\n$last", 7, 10)), intercepted) + assertEquals("before old after", editor.text.toString()) + assertEquals(10, editor.selectionStart) + assertEquals(7, editor.selectionEnd) + } + + @Test + fun regularPasteKeepsClipsWithAnyUriItemNative() { + editor.textPasteThresholdBytes = 1 + editor.pasteTextListener = { _, _, _ -> error("URI-backed clips must stay native") } + for (uriIndex in 0..2) { + val items = (0..2).map { index -> + if (index == uriIndex) { + ClipData.Item("ITEM_$index", null, Uri.parse("content://test/item/$index")) + } else { + ClipData.Item("ITEM_$index") + } + } + val clip = ClipData("test", arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN), items[0]).apply { + addItem(items[1]) + addItem(items[2]) + } + clipboard.setPrimaryClip(clip) + editor.setText("before old after") + editor.setSelection(10, 7) + + assertTrue(editor.onTextContextMenuItem(android.R.id.paste)) + assertEquals("before ITEM_0\nITEM_1\nITEM_2 after", editor.text.toString()) + } + } + @Test fun shortcutPastesStructuredClipboardAsText() { clipboard.setPrimaryClip( diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 7a659cc510ae..919902b051ca 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -7,13 +7,13 @@ private struct ComposerTokenPayload: Decodable { let label: String let iconUri: String? let accent: String? - let symbol: String? + let tintsIcon: Bool? let detail: String? let start: Int let end: Int } -private struct ComposerSelectionPayload: Decodable { +private struct ComposerSelectionPayload: Decodable, Equatable { let start: Int let end: Int } @@ -29,20 +29,6 @@ private struct ComposerControlledDocumentPayload: Decodable { private struct ComposerThemePayload: Decodable { let text: String let placeholder: String - let chipBackground: String - let chipBorder: String - let chipText: String - let skillBackground: String - let skillBorder: String - let skillText: String - let fileTint: String -} - -private struct ComposerChipStyle { - let tint: UIColor - let backgroundColor: UIColor - let borderColor: UIColor - let textColor: UIColor } private enum ComposerEnterBehavior: String { @@ -446,14 +432,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro private var requestedSelection: ComposerSelectionPayload? private var theme = ComposerThemePayload( text: "#262626", - placeholder: "#8e8e93", - chipBackground: "#f2f2f7", - chipBorder: "#dedee3", - chipText: "#262626", - skillBackground: "#f9e8fb", - skillBorder: "#e5a6eb", - skillText: "#a21caf", - fileTint: "#737373" + placeholder: "#8e8e93" ) private var fontFamily = "DMSans-Regular" private var fontSize: CGFloat = 14 @@ -470,6 +449,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro private var tokensNeedRebuild = false private var chipsNeedMeasuredWidth = false private var hasDeferredForcedRebuild = false + private var pendingContentOffset: CGPoint? + private var needsSelectionReveal = false + private var selectionBeforeFocusingTap: (selection: ComposerSelectionPayload, value: String)? let onComposerChange = EventDispatcher() let onComposerSelectionChange = EventDispatcher() @@ -485,7 +467,8 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro public required init(appContext: AppContext? = nil) { super.init(appContext: appContext) - clipsToBounds = false + clipsToBounds = true + textView.clipsToBounds = true textView.delegate = self textView.textDropDelegate = self textView.backgroundColor = .clear @@ -541,6 +524,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro @objc private func openContext(_ recognizer: UITapGestureRecognizer) { guard let (index, attachment) = contextAttachment(at: recognizer.location(in: textView)) else { textView.becomeFirstResponder() + if selectionBeforeFocusingTap != nil { + // UIKit places the caret from its own tap recognizer during this same touch. + DispatchQueue.main.async { [weak self] in + self?.restoreSelectionBeforeFocusingTap() + } + } return } openContext(index: index, attachment: attachment) @@ -579,7 +568,11 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - true + // The collapsed editor only shows the draft's first line, so a tap that expands an + // overflowing draft returns to the caret it was left at instead of moving it there. + let collapsedOverflow = !textView.isFirstResponder && !textView.isScrollEnabled && draftOverflowsBounds() + selectionBeforeFocusingTap = collapsedOverflow ? (sourceSelection(), value) : nil + return true } public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { @@ -604,11 +597,27 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro public override func layoutSubviews() { super.layoutSubviews() + let previousSize = textView.bounds.size textView.frame = bounds if chipsNeedMeasuredWidth, bounds.width > 0 { chipsNeedMeasuredWidth = false applyControlledDocument(force: true) } + if textView.isScrollEnabled, bounds.width > 0, bounds.height > 0 { + textView.layoutIfNeeded() + if let offset = pendingContentOffset { + let minY = -textView.adjustedContentInset.top + let maxY = max(minY, textView.contentSize.height - bounds.height + textView.adjustedContentInset.bottom) + textView.setContentOffset(CGPoint(x: offset.x, y: min(maxY, max(minY, offset.y))), animated: false) + pendingContentOffset = nil + } + if needsSelectionReveal || (previousSize != bounds.size && textView.isFirstResponder) { + needsSelectionReveal = false + if let selection = textView.selectedTextRange { + textView.scrollRectToVisible(textView.caretRect(for: selection.end), animated: false) + } + } + } let placeholderX = textView.textContainerInset.left + textView.textContainer.lineFragmentPadding let placeholderY = textView.textContainerInset.top let placeholderWidth = max( @@ -653,10 +662,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro tokens = decode([ComposerTokenPayload].self, from: document.tokensJson) ?? [] tokensNeedRebuild = true } + let previousSelection = sourceSelection() value = document.value requestedSelection = document.selection applyControlledDocument(force: tokensNeedRebuild) applyRequestedSelection() + cancelFocusingTapRestoreIfSelectionMoved(from: previousSelection) if tokensMatchCurrentValue() { tokensNeedRebuild = false } @@ -715,7 +726,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } func setScrollEnabled(_ scrollEnabled: Bool) { + guard textView.isScrollEnabled != scrollEnabled else { return } + if !scrollEnabled { + // Disabling scrolling discards UITextView's offset. Restore it after + // expansion has laid out the text, regardless of prop update order. + pendingContentOffset = pendingContentOffset ?? textView.contentOffset + } textView.isScrollEnabled = scrollEnabled + if scrollEnabled { + revealSelectionAfterLayout() + } } func setAutoFocus(_ autoFocus: Bool) { @@ -759,8 +779,10 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } func setSelection(start: Int, end: Int) { + let previousSelection = sourceSelection() requestedSelection = ComposerSelectionPayload(start: start, end: end) applyRequestedSelection() + cancelFocusingTapRestoreIfSelectionMoved(from: previousSelection) } public func textViewDidChange(_ textView: UITextView) { @@ -832,6 +854,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } public func textViewDidBeginEditing(_ textView: UITextView) { + revealSelectionAfterLayout() onComposerFocus() } @@ -857,6 +880,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro hasDeferredForcedRebuild = false let previousSelection = sourceSelection() + if textView.isScrollEnabled { + pendingContentOffset = pendingContentOffset ?? textView.contentOffset + } isApplyingControlledValue = true textView.attributedText = makeAttributedDocument() let targetSelection = requestedSelection ?? previousSelection @@ -864,6 +890,11 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro textView.selectedRange = displayRange(for: targetSelection) restoreBaseTypingAttributes() isApplyingControlledValue = false + if currentSource != value || previousSelection.start != targetSelection.start || previousSelection.end != targetSelection.end { + revealSelectionAfterLayout() + } else { + setNeedsLayout() + } updatePlaceholderVisibility() emitContentSizeIfNeeded() } @@ -906,40 +937,26 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } private func makeAttachmentString(_ token: ComposerTokenPayload) -> NSAttributedString { - let isSkill = token.type == "skill" - let accent = token.accent.flatMap { UIColor(composerHex: $0) } - let foreground = UIColor(composerHex: theme.chipText) ?? .label - let border = UIColor(composerHex: theme.chipBorder) ?? .separator - let tint = accent.map { blend($0, over: foreground, weight: 0.22) } - ?? UIColor(composerHex: isSkill ? theme.skillText : theme.fileTint) ?? .secondaryLabel - let iconName = token.symbol ?? (isSkill ? "cube" : "doc") - let iconImage = token.iconUri.flatMap(iconImage(for:)) - let style = ComposerChipStyle( - tint: tint, - backgroundColor: accent?.withAlphaComponent(0.11) ?? UIColor( - composerHex: isSkill ? theme.skillBackground : theme.chipBackground - ) ?? .secondarySystemFill, - borderColor: accent.map { blend($0, over: border, weight: 0.34) } ?? UIColor( - composerHex: isSkill ? theme.skillBorder : theme.chipBorder - ) ?? .separator, - textColor: tint - ) + let color = token.accent.flatMap { UIColor(composerHex: $0) } + ?? UIColor(composerHex: theme.text) ?? .label + let chipFont = UIFont(name: "DMSans-Medium", size: fontSize) + ?? UIFont.systemFont(ofSize: fontSize, weight: .medium) + let icon = token.iconUri.flatMap(iconImage(for:)).map { + token.tintsIcon == true ? Self.tinted($0, color: color) : $0 + } let image = renderChip( label: token.label, detail: token.detail, - iconName: iconName, - iconImage: iconImage, - style: style + icon: icon, + color: color, + font: chipFont ) - let font = UIFont(name: fontFamily, size: fontSize) - ?? UIFont.systemFont(ofSize: fontSize) - let baselineOffset = floor((font.capHeight - image.size.height) / 2) let attachment = ComposerTextAttachment( source: token.source, label: token.label, image: image, size: image.size, - baselineOffset: baselineOffset + baselineOffset: chipFont.ascender - image.size.height ) let attributedAttachment = NSMutableAttributedString(attachment: attachment) attributedAttachment.addAttributes( @@ -949,78 +966,35 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro return attributedAttachment } - /// Chip glyphs with no SF Symbol that reads correctly. A pull request would otherwise land on - /// `arrow.triangle.branch`, a road-sign fork that says "branch", not "pull request", so it is - /// drawn from the same lucide geometry web and Android use. - private static func vectorIcon(named name: String, size: CGFloat, color: UIColor) -> UIImage? { - guard name == "git-pull-request" else { return nil } - return UIGraphicsImageRenderer(size: CGSize(width: size, height: size)).image { _ in - let s = size / 24 // lucide authors on a 24pt grid. - let path = UIBezierPath() - for centre in [CGPoint(x: 18 * s, y: 18 * s), CGPoint(x: 6 * s, y: 6 * s)] { - path.append(UIBezierPath(arcCenter: centre, radius: 3 * s, startAngle: 0, - endAngle: .pi * 2, clockwise: true)) - } - path.move(to: CGPoint(x: 13 * s, y: 6 * s)) - path.addLine(to: CGPoint(x: 16 * s, y: 6 * s)) - path.addCurve(to: CGPoint(x: 18 * s, y: 8 * s), - controlPoint1: CGPoint(x: 17.1 * s, y: 6 * s), - controlPoint2: CGPoint(x: 18 * s, y: 6.9 * s)) - path.addLine(to: CGPoint(x: 18 * s, y: 15 * s)) - path.move(to: CGPoint(x: 6 * s, y: 9 * s)) - path.addLine(to: CGPoint(x: 6 * s, y: 21 * s)) - path.lineWidth = 2 * s - path.lineCapStyle = .round - path.lineJoinStyle = .round - color.setStroke() - path.stroke() - } - } - private func renderChip( label: String, detail: String?, - iconName: String, - iconImage: UIImage?, - style: ComposerChipStyle + icon: UIImage?, + color: UIColor, + font: UIFont ) -> UIImage { - // Kept in step with `T3ContextChipVectorIcon` in the markdown module: a chip drawn here and - // the same chip drawn in a sent message have to be the same picture. - let chipFontSize = fontSize * 0.86 - let font = UIFont(name: "DMSans-Medium", size: chipFontSize) - ?? UIFont.systemFont(ofSize: chipFontSize, weight: .medium) - let fallbackIcon = Self.vectorIcon(named: iconName, size: 14, color: style.textColor) - ?? UIImage( - systemName: iconName, - withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium) - ) - let icon = iconImage ?? fallbackIcon - // The size reads as metadata, not part of the name, so it renders a step down from the - // label the way the web chip does. let paragraph = NSMutableParagraphStyle() paragraph.alignment = .left - let detailFont = UIFont(name: "DMSans-Medium", size: chipFontSize * 0.84) - ?? UIFont.systemFont(ofSize: chipFontSize * 0.84, weight: .medium) + paragraph.lineBreakMode = .byTruncatingMiddle let attributedLabel = NSMutableAttributedString( string: label, - attributes: [.font: font, .foregroundColor: style.textColor, .paragraphStyle: paragraph] + attributes: [.font: font, .foregroundColor: color, .paragraphStyle: paragraph] ) + // The size reads as metadata, not part of the name, so it renders a step down from the + // label the way the web chip does. if let detail, !detail.isEmpty { + let detailFont = font.withSize(font.pointSize * 0.84) attributedLabel.append( NSAttributedString( string: " \(detail)", - attributes: [ - .font: detailFont, - .foregroundColor: style.textColor, - .paragraphStyle: paragraph, - ] + attributes: [.font: detailFont, .foregroundColor: color, .paragraphStyle: paragraph] ) ) } - let iconWidth: CGFloat = icon == nil ? 0 : chipFontSize * 1.17 - let iconGap: CGFloat = icon == nil ? 0 : chipFontSize * 0.33 - let padding = chipFontSize * 0.5 - let height = ceil(chipFontSize * 1.41) + let iconSide = font.pointSize + let iconSlot: CGFloat = icon == nil ? 0 : iconSide * 1.25 + let padding: CGFloat = 5 + let height = ceil(font.ascender - font.descender) // A long path would otherwise draw a chip wider than the composer and clip. Cap the label // to the text the editor can actually show and truncate inside it, as Android's // `maximumWidth` does, so the chip always fits the line it sits on. @@ -1030,58 +1004,35 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro if availableWidth <= 0 { chipsNeedMeasuredWidth = true } - let maximumLabelWidth = max(chipFontSize * 3, availableWidth - padding * 2 - iconWidth - iconGap) - paragraph.lineBreakMode = .byTruncatingMiddle - attributedLabel.addAttribute( - .paragraphStyle, - value: paragraph, - range: NSRange(location: 0, length: attributedLabel.length) - ) - let measured = attributedLabel.size() - let textSize = CGSize(width: min(measured.width, maximumLabelWidth), height: measured.height) - let width = ceil(padding * 2 + iconWidth + iconGap + textSize.width) + let maximumLabelWidth = max(font.pointSize * 3, availableWidth - padding * 2 - iconSlot) + let labelWidth = min(attributedLabel.size().width, maximumLabelWidth) + let width = ceil(padding * 2 + iconSlot + labelWidth) let format = UIGraphicsImageRendererFormat.preferred() format.opaque = false let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format) - return renderer.image { context in - let rect = CGRect(origin: .zero, size: CGSize(width: width, height: height)) - let path = UIBezierPath(roundedRect: rect.insetBy(dx: 0.5, dy: 0.5), cornerRadius: chipFontSize * 0.5) - style.backgroundColor.setFill() - path.fill() - style.borderColor.setStroke() - path.lineWidth = 1 - path.stroke() - - var x = padding + return renderer.image { _ in if let icon { - let renderedIcon = iconImage == nil - ? icon.withTintColor(style.tint, renderingMode: .alwaysOriginal) - : icon - renderedIcon.draw( - in: CGRect(x: x, y: (height - iconWidth) / 2, width: iconWidth, height: iconWidth) + icon.draw( + in: CGRect(x: padding, y: (height - iconSide) / 2, width: iconSide, height: iconSide) ) - x += iconWidth + iconGap } // Exactly the measured width: a spare pixel here would let a capped label draw past // the cap instead of truncating inside it. attributedLabel.draw( - in: CGRect(x: x, y: (height - textSize.height) / 2, width: textSize.width, height: textSize.height) + in: CGRect(x: padding + iconSlot, y: 0, width: labelWidth, height: height) ) - context.cgContext.setAllowsAntialiasing(true) } } - private func blend(_ accent: UIColor, over base: UIColor, weight: CGFloat) -> UIColor { - var ar: CGFloat = 0, ag: CGFloat = 0, ab: CGFloat = 0, aa: CGFloat = 0 - var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0 - accent.getRed(&ar, green: &ag, blue: &ab, alpha: &aa) - base.getRed(&br, green: &bg, blue: &bb, alpha: &ba) - return UIColor( - red: ar * weight + br * (1 - weight), - green: ag * weight + bg * (1 - weight), - blue: ab * weight + bb * (1 - weight), - alpha: aa * weight + ba * (1 - weight) - ) + private static func tinted(_ image: UIImage, color: UIColor) -> UIImage { + let format = UIGraphicsImageRendererFormat.preferred() + format.scale = image.scale + format.opaque = false + return UIGraphicsImageRenderer(size: image.size, format: format).image { _ in + color.setFill() + UIRectFill(CGRect(origin: .zero, size: image.size)) + image.draw(at: .zero, blendMode: .destinationIn, alpha: 1) + } } private func iconImage(for uri: String) -> UIImage? { @@ -1154,6 +1105,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro return } value = textView.serializedText() + revealSelectionAfterLayout() let selection = sourceSelection() nativeEventCount += 1 onComposerChange([ @@ -1223,6 +1175,40 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro isApplyingControlledValue = true textView.selectedRange = nextRange isApplyingControlledValue = false + revealSelectionAfterLayout() + } + + private func restoreSelectionBeforeFocusingTap() { + guard let snapshot = selectionBeforeFocusingTap else { + return + } + selectionBeforeFocusingTap = nil + // A draft that changed during the touch, such as a transcript landing, owns the caret now. + guard textView.isFirstResponder, textView.markedTextRange == nil, value == snapshot.value else { + return + } + let range = displayRange(for: snapshot.selection) + guard !NSEqualRanges(range, textView.selectedRange) else { + return + } + textView.selectedRange = range + revealSelectionAfterLayout() + } + + private func cancelFocusingTapRestoreIfSelectionMoved(from previousSelection: ComposerSelectionPayload) { + if sourceSelection() != previousSelection { + selectionBeforeFocusingTap = nil + } + } + + private func draftOverflowsBounds() -> Bool { + let size = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude)) + return size.height > textView.bounds.height + 1 + } + + private func revealSelectionAfterLayout() { + needsSelectionReveal = true + setNeedsLayout() } private func updatePlaceholderVisibility() { @@ -1230,7 +1216,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } private func emitContentSizeIfNeeded() { - let nextSize = textView.contentSize + guard bounds.width > 0 else { return } + // Fit independently of the animated viewport: when scrolling is disabled, + // its current height can clip TextKit's laid-out line fragments. + let fittedHeight = textView.sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)).height + - textView.textContainerInset.top - textView.textContainerInset.bottom + let measuredHeight = min(lineHeight * 4, fittedHeight) + let width = fittedHeight <= lineHeight + 0.5 + ? textView.layoutManager.usedRect(for: textView.textContainer).width + : 0 + let nextSize = CGSize(width: ceil(width), height: ceil(measuredHeight)) guard abs(nextSize.width - lastContentSize.width) > 0.5 || abs(nextSize.height - lastContentSize.height) > 0.5 else { return diff --git a/apps/mobile/modules/t3-native-controls/android/build.gradle b/apps/mobile/modules/t3-native-controls/android/build.gradle index 01e035450f0e..9ecf11f68dbc 100644 --- a/apps/mobile/modules/t3-native-controls/android/build.gradle +++ b/apps/mobile/modules/t3-native-controls/android/build.gradle @@ -26,6 +26,7 @@ android { dependencies { implementation project(':expo-modules-core') implementation 'androidx.compose.ui:ui-android:1.10.6' + implementation 'androidx.activity:activity:1.8.2' implementation 'androidx.compose.material3:material3-android:1.5.0-alpha17' implementation 'androidx.window:window:1.5.1' } diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt index 3d48060dcaf5..3a924af4889e 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt @@ -2,25 +2,60 @@ package expo.modules.t3nativecontrols import android.content.Context import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Scrim +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp import expo.modules.kotlin.AppContext import expo.modules.kotlin.modules.Module @@ -28,6 +63,8 @@ import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView +private val TABS = listOf("threads", "pullRequests", "settings") + class T3WorkspaceNavigationModule : Module() { override fun definition() = ModuleDefinition { Name("T3WorkspaceNavigation") @@ -35,8 +72,21 @@ class T3WorkspaceNavigationModule : Module() { Prop("selectedTab") { view: T3WorkspaceNavigationView, value: String -> view.selectedTab = value } + Prop("rail") { view: T3WorkspaceNavigationView, value: Boolean -> view.rail = value } Prop("dark") { view: T3WorkspaceNavigationView, value: Boolean -> view.dark = value } Prop("textScale") { view: T3WorkspaceNavigationView, value: Float -> view.textScale = value } + Prop("newEnabled") { view: T3WorkspaceNavigationView, value: Boolean -> + view.newEnabled = value + } + Prop("safeAreaTop") { view: T3WorkspaceNavigationView, value: Float -> + view.safeAreaTop = value + } + Prop("safeAreaBottom") { view: T3WorkspaceNavigationView, value: Float -> + view.safeAreaBottom = value + } + Prop("headerHeight") { view: T3WorkspaceNavigationView, value: Float -> + view.headerHeight = value + } Prop("containerColor") { view: T3WorkspaceNavigationView, value: Int -> view.containerColor = value } @@ -49,12 +99,18 @@ class T3WorkspaceNavigationModule : Module() { Prop("unselectedColor") { view: T3WorkspaceNavigationView, value: Int -> view.unselectedColor = value } - Events("onTabSelected") + Prop("actionColor") { view: T3WorkspaceNavigationView, value: Int -> + view.actionColor = value + } + Prop("actionContentColor") { view: T3WorkspaceNavigationView, value: Int -> + view.actionContentColor = value + } + Events("onTabSelected", "onNewThread", "onOverlayChange") } } } -/** Material3 bottom navigation for the compact workspace. */ +/** One Material3 selection surface moves from below the list to its leading edge. */ class T3WorkspaceNavigationView( context: Context, appContext: AppContext @@ -63,13 +119,22 @@ class T3WorkspaceNavigationView( override val shouldUseAndroidLayout = true private val onTabSelected by EventDispatcher() + private val onNewThread by EventDispatcher() + private val onOverlayChange by EventDispatcher() var selectedTab by mutableStateOf("threads") + var rail by mutableStateOf(false) var dark by mutableStateOf(false) var textScale by mutableStateOf(1f) + var newEnabled by mutableStateOf(false) + var safeAreaTop by mutableStateOf(0f) + var safeAreaBottom by mutableStateOf(0f) + var headerHeight by mutableStateOf(56f) var containerColor by mutableStateOf(0) var selectedColor by mutableStateOf(0) var indicatorColor by mutableStateOf(0) var unselectedColor by mutableStateOf(0) + var actionColor by mutableStateOf(0) + var actionContentColor by mutableStateOf(0) override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { // Fabric measures the replacement bar before attaching it on a fold. @@ -101,30 +166,30 @@ class T3WorkspaceNavigationView( ) setContent { MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme()) { - val tabs = listOf("threads", "pullRequests") - NavigationBar( - modifier = Modifier.fillMaxSize(), - containerColor = Color(containerColor), - tonalElevation = androidx.compose.ui.unit.Dp(0f), - windowInsets = WindowInsets(0), - ) { - tabs.forEach { tab -> - NavigationBarItem( - selected = selectedTab == tab, - onClick = { onTabSelected(mapOf("tab" to tab)) }, - icon = { tabIcon(tab) }, - label = { - val title = if (tab == "threads") "Chats" else "Pull requests" - Text(title, fontSize = (12 * textScale).sp) - }, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = Color(selectedColor), - selectedTextColor = Color(selectedColor), - indicatorColor = Color(indicatorColor), - unselectedIconColor = Color(unselectedColor), - unselectedTextColor = Color(unselectedColor), - ), - ) + if (rail) { + rail() + } else { + NavigationBar( + modifier = Modifier.fillMaxSize(), + containerColor = Color(containerColor), + tonalElevation = androidx.compose.ui.unit.Dp(0f), + windowInsets = WindowInsets(0), + ) { + TABS.forEach { tab -> + NavigationBarItem( + selected = selectedTab == tab, + onClick = { onTabSelected(mapOf("tab" to tab)) }, + icon = { Icon(tabIconPainter(tab), contentDescription = null) }, + label = { Text(tabTitle(tab), fontSize = (12 * textScale).sp) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = Color(selectedColor), + selectedTextColor = Color(selectedColor), + indicatorColor = Color(indicatorColor), + unselectedIconColor = Color(unselectedColor), + unselectedTextColor = Color(unselectedColor), + ), + ) + } } } } @@ -135,15 +200,192 @@ class T3WorkspaceNavigationView( } @Composable - private fun tabIcon(tab: String) { - val drawable = if (tab == "threads") { - R.drawable.t3_chats - } else { - R.drawable.t3_pull_requests - } - Icon( - painter = painterResource(drawable), - contentDescription = if (tab == "threads") "Chats" else "Pull requests", + private fun rail() { + // One rail surface widens in place over the list. Material's ModalWideNavigationRail + // instead moves its destinations into a dialog window copy, which blanks them for a few + // frames while the window attaches and lays them out at a different height. + var expanded by remember { mutableStateOf(false) } + // React Native sizes this view to the rail. While the rail is open it spans the workspace, + // and it only widens once the host has made room, so the surface is never clipped. + var roomy by remember { mutableStateOf(false) } + val progress by animateFloatAsState( + targetValue = if (expanded && roomy) 1f else 0f, + animationSpec = MaterialTheme.motionScheme.defaultSpatialSpec(), ) + val overlay = expanded || progress != 0f + LaunchedEffect(overlay) { onOverlayChange(mapOf("overlay" to overlay)) } + // The activity handles Back before React Native, so an open rail closes first. + val backDispatcher = (appContext.currentActivity as? ComponentActivity)?.onBackPressedDispatcher + DisposableEffect(backDispatcher, expanded) { + val callback = object : OnBackPressedCallback(expanded) { + override fun handleOnBackPressed() { + expanded = false + } + } + backDispatcher?.addCallback(callback) + onDispose { callback.remove() } + } + val density = LocalDensity.current + Box( + Modifier.fillMaxSize().onSizeChanged { + roomy = with(density) { it.width.toDp() } >= EXPANDED_RAIL_WIDTH + }, + ) { + val fraction = progress.coerceIn(0f, 1f) + if (fraction > 0f) { + Scrim( + contentDescription = "Close navigation rail", + onClick = { expanded = false }, + alpha = { fraction }, + ) + } + Column( + Modifier + .fillMaxHeight() + .width(lerp(RAIL_WIDTH, EXPANDED_RAIL_WIDTH, progress.coerceAtLeast(0f))) + .shadow(RAIL_SHADOW * fraction, RAIL_EXPANDED_SHAPE, clip = false) + .background(Color(containerColor), RAIL_EXPANDED_SHAPE) + .padding(top = safeAreaTop.dp, bottom = safeAreaBottom.dp + 12.dp) + .selectableGroup(), + ) { + // As tall as the list toolbar so the menu lines up with the toolbar's actions. + Box( + Modifier.height(headerHeight.dp).padding(start = RAIL_INSET), + contentAlignment = Alignment.CenterStart, + ) { + IconButton( + onClick = { expanded = !expanded }, + modifier = Modifier.semantics { + contentDescription = + if (expanded) "Collapse navigation rail" else "Expand navigation rail" + }, + ) { + Icon( + painterResource(R.drawable.t3_menu), + contentDescription = null, + tint = Color(unselectedColor), + ) + } + } + newThreadButton(fraction) { + expanded = false + onNewThread(emptyMap()) + } + Spacer(Modifier.weight(1f)) + TABS.forEach { tab -> + railItem(tab, fraction) { + expanded = false + onTabSelected(mapOf("tab" to tab)) + } + } + Spacer(Modifier.weight(1f)) + } + } + } + + /** The collapsed FAB widens with the rail and reveals its label as it goes. */ + @Composable + private fun newThreadButton(fraction: Float, onClick: () -> Unit) { + Surface( + onClick = onClick, + enabled = newEnabled, + shape = MaterialTheme.shapes.large, + color = Color(actionColor), + contentColor = Color(actionContentColor), + shadowElevation = 3.dp, + modifier = Modifier + .padding(start = RAIL_INSET, top = 12.dp) + .height(FAB_SIZE) + .width(lerp(FAB_SIZE, EXPANDED_RAIL_WIDTH - RAIL_INSET * 2, fraction)) + .alpha(if (newEnabled) 1f else DISABLED_ALPHA) + .semantics { contentDescription = "New thread" }, + ) { + Row( + Modifier.padding(start = (FAB_SIZE - ICON_SIZE) / 2).clipToBounds(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(painterResource(R.drawable.t3_new_thread), contentDescription = null) + Text( + "New thread", + Modifier.padding(start = 12.dp).alpha(labelAlpha(fraction)).clearAndSetSemantics {}, + style = MaterialTheme.typography.labelLarge, + fontSize = (14 * textScale).sp, + maxLines = 1, + softWrap = false, + ) + } + } + } + + /** + * A destination whose indicator stretches from a circle around the icon into a pill around + * the icon and label. The icon never moves, so the collapsed and expanded rails share it. + */ + @Composable + private fun railItem(tab: String, fraction: Float, onClick: () -> Unit) { + val selected = selectedTab == tab + val title = expandedTabTitle(tab) + val tint = Color(if (selected) selectedColor else unselectedColor) + Box( + Modifier + .padding(start = RAIL_INSET, bottom = 4.dp) + .height(ITEM_HEIGHT) + .width(lerp(ITEM_HEIGHT, EXPANDED_RAIL_WIDTH - RAIL_INSET * 2, fraction)) + .clip(CircleShape) + .background(if (selected) Color(indicatorColor) else Color.Transparent) + .selectable(selected = selected, role = Role.Tab, onClick = onClick) + .semantics { contentDescription = title }, + ) { + Row( + Modifier.fillMaxHeight().padding(start = (ITEM_HEIGHT - ICON_SIZE) / 2).clipToBounds(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(tabIconPainter(tab), contentDescription = null, tint = tint) + Text( + title, + Modifier.padding(start = 12.dp).alpha(labelAlpha(fraction)).clearAndSetSemantics {}, + color = tint, + style = MaterialTheme.typography.labelLarge, + fontSize = (14 * textScale).sp, + maxLines = 1, + softWrap = false, + ) + } + } + } + + private fun tabTitle(tab: String) = when (tab) { + "threads" -> "Chats" + "pullRequests" -> "PRs" + else -> "Settings" + } + + private fun expandedTabTitle(tab: String) = + if (tab == "pullRequests") "Pull Requests" else tabTitle(tab) + + @Composable + private fun tabIconPainter(tab: String) = painterResource( + when (tab) { + "threads" -> R.drawable.t3_chats + "pullRequests" -> R.drawable.t3_pull_requests + else -> R.drawable.t3_settings + } + ) + + private companion object { + // Material's 80dp compact rail, minus 8dp: still a 48dp touch target with 12dp margins. + val RAIL_WIDTH = 72.dp + val EXPANDED_RAIL_WIDTH = 240.dp + val RAIL_INSET = 12.dp + val ITEM_HEIGHT = 48.dp + val FAB_SIZE = 48.dp + val ICON_SIZE = 24.dp + val RAIL_SHADOW = 6.dp + val RAIL_EXPANDED_SHAPE = RoundedCornerShape(topEnd = 16.dp, bottomEnd = 16.dp) + const val DISABLED_ALPHA = 0.4f + + // Labels fade in over the second half of the widening, once there is room for them, and + // fade out over the first half of the narrowing, before the surface clips them. + fun labelAlpha(fraction: Float) = ((fraction - 0.5f) / 0.5f).coerceIn(0f, 1f) } } diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_menu.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_menu.xml new file mode 100644 index 000000000000..d483560d3785 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_menu.xml @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_new_thread.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_new_thread.xml new file mode 100644 index 000000000000..109e695d0168 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_new_thread.xml @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_settings.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_settings.xml new file mode 100644 index 000000000000..3c4dfa57233e --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_settings.xml @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request.png new file mode 100644 index 000000000000..7443f8e0096d Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@2x.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@2x.png new file mode 100644 index 000000000000..efc4cb3a093d Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@2x.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@3x.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@3x.png new file mode 100644 index 000000000000..ee63420dba56 Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@3x.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift index 936efe7676e9..b970936b1fe8 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift @@ -69,6 +69,7 @@ final class T3LayoutMetricsView: ExpoView { } var verticalBarEdge = "none" var regions: [[String: Any]] = [] + var bottomBarInset: CGFloat? #if canImport(UIKit, _version: 9127.0.85) if #available(iOS 27.1, *) { let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft @@ -77,6 +78,8 @@ final class T3LayoutMetricsView: ExpoView { case .trailing: verticalBarEdge = rtl ? "left" : "right" default: break } + // Where UIKit floats a bar as tall as the app's bottom controls, level with its tab bar. + bottomBarInset = edgeInsets(for: .bar(onEdge: UIRectEdge.bottom, extent: 48)).bottom for kind in [UIView.ReservedRegion.Kind.division, .occlusion] { for region in reservedRegions(kind: kind) where region.isActive { let frame = region.frame.intersection(bounds) @@ -90,7 +93,7 @@ final class T3LayoutMetricsView: ExpoView { } } #endif - let metrics: [String: Any] = [ + var metrics: [String: Any] = [ "width": bounds.width, "height": bounds.height, "hasHinge": hasHinge, "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact", @@ -101,6 +104,7 @@ final class T3LayoutMetricsView: ExpoView { ], "reservedRegions": regions, ] + metrics["bottomBarInset"] = bottomBarInset let next = metrics as NSDictionary guard lastMetrics?.isEqual(next) != true else { return } lastMetrics = next diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec b/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec index 29735c301cc0..c32f88b0dacb 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec @@ -15,5 +15,6 @@ Pod::Spec.new do |s| s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', } + s.resource_bundles = { 'T3NativeControlIcons' => ['Resources/Icons/pull-request*.png'] } s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}' end diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index e6977ee71cd4..81a892c8becf 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -32,6 +32,14 @@ public final class T3NativeControlsModule: Module { Prop("appearance") { (view: T3SegmentedControlView, value: String) in view.appearance = value } } + View(T3SidebarTabBarView.self) { + ViewName("SidebarTabBar") + Events("onSelectionChange") + Prop("selectedIndex") { (view: T3SidebarTabBarView, value: Int) in view.selectedIndex = value } + Prop("selectedColor") { (view: T3SidebarTabBarView, value: UIColor) in view.selectedColor = value } + Prop("appearance") { (view: T3SidebarTabBarView, value: String) in view.appearance = value } + } + View(T3LayoutMetricsView.self) { ViewName("LayoutMetrics") Events("onMetricsChange") @@ -167,6 +175,12 @@ public final class T3NativeControlsModule: Module { return bounds.width > bounds.height ? "landscape" : "portrait" } + @JS + @MainActor + func isProtectedDataAvailable() async -> Bool { + UIApplication.shared.isProtectedDataAvailable + } + private func launchArgument(_ flag: String) -> String? { let arguments = ProcessInfo.processInfo.arguments guard diff --git a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift new file mode 100644 index 000000000000..3bfd730facd1 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift @@ -0,0 +1,60 @@ +import ExpoModulesCore +import UIKit + +final class T3SidebarTabBarView: ExpoView, UITabBarDelegate { + let onSelectionChange = EventDispatcher() + private let tabBar = UITabBar() + var selectedIndex = 0 { didSet { updateSelection() } } + var selectedColor: UIColor = .systemBlue { didSet { tabBar.tintColor = selectedColor } } + var appearance = "light" { + didSet { overrideUserInterfaceStyle = appearance == "dark" ? .dark : .light } + } + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + let iconBundle = Bundle(for: Self.self).url(forResource: "T3NativeControlIcons", withExtension: "bundle").flatMap(Bundle.init(url:)) + let pullRequestIcon = UIImage(named: "pull-request", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate) + tabBar.items = [ + UITabBarItem(title: "Threads", image: tabIcon(UIImage(systemName: "text.bubble")), tag: 0), + UITabBarItem(title: "Pulls", image: tabIcon(pullRequestIcon), tag: 1), + ] + tabBar.items?.last?.accessibilityLabel = "Pull requests" + tabBar.items?.first?.selectedImage = tabIcon(UIImage(systemName: "text.bubble.fill")) + tabBar.itemPositioning = .fill + tabBar.delegate = self + addSubview(tabBar) + updateSelection() + } + + override func layoutSubviews() { + super.layoutSubviews() + tabBar.frame = bounds + updateSelection() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + updateSelection() + } + + // Give symbols and bitmap assets the same image box; UIKit otherwise scales symbols independently. + private func tabIcon(_ image: UIImage?) -> UIImage? { + guard let image else { return nil } + let canvas = CGSize(width: 28, height: 28) + let scale = min(24 / image.size.width, 24 / image.size.height) + let size = CGSize(width: image.size.width * scale, height: image.size.height * scale) + return UIGraphicsImageRenderer(size: canvas).image { _ in + image.draw(in: CGRect(x: (canvas.width - size.width) / 2, y: (canvas.height - size.height) / 2, width: size.width, height: size.height)) + }.withRenderingMode(.alwaysTemplate) + } + + private func updateSelection() { + guard let items = tabBar.items, items.indices.contains(selectedIndex) else { return } + guard tabBar.selectedItem !== items[selectedIndex] else { return } + tabBar.selectedItem = items[selectedIndex] + } + + func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { + onSelectionChange(["index": item.tag]) + } +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt index 80d0410643ff..67579a1cad88 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt @@ -10,6 +10,7 @@ import android.graphics.RectF import android.graphics.Shader import android.graphics.Typeface import android.text.TextPaint +import kotlin.math.ceil import kotlin.math.max import kotlin.math.min @@ -211,6 +212,68 @@ internal class ReviewDiffCanvasDrawing(context: Context) { return codeLayouts.layout(rows, tokens, textPaint, style, theme, availableWidth.toInt()) } + private data class WidthEntry(val row: DiffRow, val tokens: List?, val width: Float) + private var widthEntries = emptyMap() + private var widthRows: List? = null + private var widthTokens: Map>? = null + private var widthStyle: DiffStyle? = null + private var measuredContentWidth = 0 + + fun contentWidth( + rows: List, + tokens: Map>, + style: DiffStyle + ): Int { + if (style.wordWrap) { + widthEntries = emptyMap() + widthRows = null + widthTokens = null + widthStyle = null + return 0 + } + // Comment heights and viewport resizing do not change unwrapped text widths. + if (widthRows !== rows || widthTokens !== tokens || widthStyle != style) { + if (widthStyle != style) widthEntries = emptyMap() + val next = HashMap() + var widest = 0f + for (row in rows) { + if (row.kind != "line" && row.kind != "hunk") continue + val rowTokens = if (row.kind == "line") tokens[row.id] else null + val cached = widthEntries[row.id] + val entry = if (cached?.row == row && cached.tokens == rowTokens) { + cached + } else { + WidthEntry(row, rowTokens, measureRowWidth(row, rowTokens, style)) + } + next[row.id] = entry + widest = max(widest, entry.width) + } + widthEntries = next + widthRows = rows + widthTokens = tokens + widthStyle = style + measuredContentWidth = ceil( + style.changeBarWidthPx + style.gutterWidthPx + style.codePaddingPx * 2f + widest, + ).toInt() + } + return measuredContentWidth + } + + private fun measureRowWidth(row: DiffRow, tokens: List?, style: DiffStyle): Float { + if (row.kind == "hunk") { + configureMonospacePaint(theme.hunkText, style.hunkFontSizePx, style.hunkFontWeight) + return textPaint.measureText(row.text.ifEmpty { row.content }) + } + val runs = if (tokens.isNullOrEmpty()) listOf(DiffToken(row.content, null, 0)) else tokens + var width = 0f + // Match drawCode's run boundaries and fonts, including fallback glyphs and tabs. + for (run in runs) { + configureCodePaint(run.color ?: theme.text, run.fontStyle, style) + width += textPaint.measureText(run.content) + } + return width + } + fun lineNumberColor(change: String): Int = when (change) { "add" -> theme.addText "delete" -> theme.deleteText diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt index 58422149016c..ee6740cf34c3 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt @@ -38,9 +38,8 @@ class T3ReviewDiffModule : Module() { Prop("rowHeight") { view: T3ReviewDiffView, rowHeight: Double -> view.setRowHeight(rowHeight.toFloat()) } - Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double -> - view.setContentWidth(contentWidth.toFloat()) - } + // Accept the shared prop while deriving Android's scroll range from the drawn text. + Prop("contentWidth") { _: T3ReviewDiffView, _: Double -> } Prop("contentInsetBottom") { view: T3ReviewDiffView, value: Double -> view.setContentInsetBottom(value.toFloat()) } diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 495a63719a74..e39fdeb96340 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -139,10 +139,6 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont canvasView.style = canvasView.style.copy(rowHeightPx = dp(value)) } - fun setContentWidth(value: Float) { - canvasView.contentWidthPx = max(width, dp(value).toInt()) - } - fun setContentInsetBottom(value: Float) { canvasView.setPadding(0, 0, 0, max(0, dp(value).toInt())) canvasView.setVerticalOffset(canvasView.verticalOffset()) @@ -718,6 +714,8 @@ private class DiffCanvasView(context: Context) : View(context) { private var codeWrap = CodeWrapLayout.NONE private var verticalOffset = 0 private var horizontalOffset = 0 + + private var contentWidthPx = 0 private val headerPathOffsetsByFileId = mutableMapOf() private var lastVisibleRange: Pair? = null var rows: List = emptyList() @@ -731,7 +729,12 @@ private class DiffCanvasView(context: Context) : View(context) { var tokensByRowId: Map> = emptyMap() set(value) { field = value - if (style.wordWrap) rebuildOffsets() + if (style.wordWrap) { + rebuildOffsets() + } else { + contentWidthPx = drawing.contentWidth(rows, value, style) + setHorizontalOffset(horizontalOffset) + } invalidate() } var viewedFileIds: Set = emptySet() @@ -766,13 +769,6 @@ private class DiffCanvasView(context: Context) : View(context) { field = value rebuildOffsets() } - var contentWidthPx: Int = (1200 * density).toInt() - set(value) { - field = max(value, suggestedMinimumWidth) - setHorizontalOffset(horizontalOffset) - clampHeaderPathOffsets() - invalidate() - } var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null var onContentHeightChanged: ((Int) -> Unit)? = null @@ -894,6 +890,7 @@ private class DiffCanvasView(context: Context) : View(context) { } private fun layoutRows() { + contentWidthPx = drawing.contentWidth(rows, tokensByRowId, style) codeWrap = drawing.codeWrapLayout(rows, tokensByRowId, style, width) rowOffsets = IntArray(rows.size + 1) rows.forEachIndexed { index, row -> diff --git a/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt index 1f4234b7175f..7c4f531fbecb 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt @@ -14,6 +14,7 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode @@ -141,6 +142,57 @@ class ReviewDiffCodeLayoutTest { assertEquals(21, text.getSpanEnd(span)) } + @Test + fun cachedContentWidthMatchesFreshMeasurementAfterUpdatesAndRemoval() { + val context = RuntimeEnvironment.getApplication() + val drawing = ReviewDiffCanvasDrawing(context) + val style = DiffStyle.defaults(1f) + fun assertFresh( + rows: List, + tokens: Map> = emptyMap(), + currentStyle: DiffStyle = style + ) { + assertEquals( + ReviewDiffCanvasDrawing(context).contentWidth(rows, tokens, currentStyle), + drawing.contentWidth(rows, tokens, currentStyle), + ) + } + + val line = row("مرحبا\t漢字 e\u0301 ".repeat(20)) + val rows = listOf(line) + val tokens = mapOf( + line.id to listOf( + DiffToken("مرحبا", Color.RED, 2), + DiffToken(line.content.drop(5), Color.BLUE, 1), + ), + ) + assertFresh(rows) + assertFresh(rows, tokens) + assertFresh(rows, tokens) + assertFresh( + rows, + tokens, + style.copy( + codeFontSizePx = 24f, + codeFontWeight = "bold", + gutterWidthPx = 90f, + codePaddingPx = 16f, + changeBarWidthPx = 5f, + ), + ) + + val hunk = line.copy(kind = "hunk", id = "hunk", text = "@@ context @@ ".repeat(100)) + val hunkRows = listOf(line, hunk) + assertFresh(hunkRows, tokens) + assertFresh(hunkRows, tokens, style.copy(hunkFontSizePx = 22f, hunkFontWeight = "bold")) + val shortHunk = hunk.copy(text = "@@ short @@") + assertFresh(listOf(line, shortHunk), tokens) + assertFresh(listOf(shortHunk), tokens) + assertFresh(rows, tokens, style.copy(wordWrap = true)) + assertFresh(rows, tokens) + assertFresh(rows) + } + private fun row(content: String) = DiffRow( kind = "line", id = "line", fileId = "file", filePath = "test.ts", previousPath = null, changeType = "modified", additions = 1, deletions = 0, text = "", content = content, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index fc6eada47d6f..9f1a94448508 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -40,11 +40,12 @@ "config:preview": "APP_VARIANT=preview expo config", "config:prod": "APP_VARIANT=production expo config", "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review", + "sync:chip-icons": "node scripts/sync-context-chip-icons.mjs", "sync:pierre-icons": "node scripts/sync-pierre-file-icons.mjs", "measure-renders": "node scripts/measure-renders.mts", "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts", - "test": "vp test run --config ../../vite.config.ts --dir .", - "typecheck": "tsc-rs --noEmit" + "test": "vp test run --project unit --project react", + "typecheck": "tsc --noEmit" }, "dependencies": { "@clerk/expo": "catalog:", @@ -125,7 +126,7 @@ "react-dom": "catalog:", "react-native": "0.88.0", "react-native-blob-util": "0.25.1", - "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.15.tgz", + "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.15-fee84114.tgz", "react-native-gesture-handler": "~3.3.0", "react-native-image-viewing": "^0.2.2", "react-native-keyboard-controller": "1.22.7", @@ -133,7 +134,7 @@ "react-native-pdf-jsi": "5.0.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.27.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.36.tgz", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.16.0", "react-native-webview": "14.0.1", @@ -143,7 +144,7 @@ "shiki": "4.2.0", "tailwind-merge": "^3.5.0", "unified": "catalog:", - "uniwind": "1.12.1" + "uniwind": "1.13.0" }, "devDependencies": { "@babel/core": "^7.29.7", @@ -159,7 +160,7 @@ "typescript": "catalog:" }, "enriched-markdown": { - "enableMath": false, + "enableMath": true, "enableVideo": false }, "expo": { @@ -179,7 +180,8 @@ "react-native-screens", "react-native-reanimated", "@react-native-menu/menu", - "expo-audio" + "expo-audio", + "expo-network" ] } }, diff --git a/apps/mobile/plugins/withAndroidFbjniVersion.test.mjs b/apps/mobile/plugins/withAndroidFbjniVersion.test.mjs index 388afbf3f67d..c221c14ca447 100644 --- a/apps/mobile/plugins/withAndroidFbjniVersion.test.mjs +++ b/apps/mobile/plugins/withAndroidFbjniVersion.test.mjs @@ -86,6 +86,7 @@ describe("withAndroidFbjniVersion", () => { expect(twice).toBe(once); }); + // regression: #20118 fresh Android builds no longer crash on launch it("writes Kotlin syntax into a Kotlin build script", async () => { const result = await prebuildRootGradle("build.gradle.kts", "plugins {}\n"); diff --git a/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift b/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift new file mode 100644 index 000000000000..db279d0c62e7 --- /dev/null +++ b/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift @@ -0,0 +1,80 @@ +import Foundation + +enum NWInterface { + enum InterfaceType { + case other, wifi, cellular, loopback, wiredEthernet + } +} + +struct NWPath { + enum Status { + case satisfied, unsatisfied + } + + // A reference inside the value models the native path's retained storage. + final class Storage { + let type: NWInterface.InterfaceType + init(_ type: NWInterface.InterfaceType) { self.type = type } + } + + let status: Status + let storage: Storage + + init(_ type: NWInterface.InterfaceType, status: Status = .satisfied) { + self.status = status + storage = Storage(type) + } + + func usesInterfaceType(_ type: NWInterface.InterfaceType) -> Bool { + storage.type == type + } +} + +final class NWPathMonitor { + static var scenario = "first" + static let deliveries = DispatchGroup() + var pathUpdateHandler: ((NWPath) -> Void)? + + func start(queue: DispatchQueue) { + guard let handler = pathUpdateHandler else { fatalError("Missing path handler") } + if Self.scenario == "offline" { + handler(NWPath(.wifi, status: .unsatisfied)) + } else if Self.scenario == "first" { + handler(NWPath(.wifi)) + handler(NWPath(.cellular)) + } else { + queue.async(group: Self.deliveries) { + handler(NWPath(.wifi)) + DispatchQueue.concurrentPerform(iterations: 128) { index in + handler(NWPath(index.isMultiple(of: 2) ? .cellular : .wiredEthernet)) + } + } + } + } + + // Already queued callbacks can run after cancellation. + func cancel() {} +} + +@main +enum NetworkModuleRegression { + static func main() { + NWPathMonitor.scenario = CommandLine.arguments[1] + let module = NetworkModule() + if NWPathMonitor.scenario == "concurrent" { + DispatchQueue.concurrentPerform(iterations: 64) { _ in + let state = module.getNetworkStateAsync() + precondition(state["type"] as? String == "WIFI", "The first path must survive late callbacks") + precondition(state["isConnected"] as? Bool == true) + } + NWPathMonitor.deliveries.wait() + } else { + let state = module.getNetworkStateAsync() + let connected = NWPathMonitor.scenario != "offline" + precondition(state["type"] as? String == (connected ? "WIFI" : "NONE")) + precondition(state["isConnected"] as? Bool == connected) + precondition(state["isInternetReachable"] as? Bool == connected) + } + print("passed") + } +} diff --git a/apps/mobile/scripts/generate-uniwind-themes.test.ts b/apps/mobile/scripts/generate-uniwind-themes.test.ts index a225b7087c86..25ed7dd41d27 100644 --- a/apps/mobile/scripts/generate-uniwind-themes.test.ts +++ b/apps/mobile/scripts/generate-uniwind-themes.test.ts @@ -2,61 +2,9 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import { describe, expect, it } from "vite-plus/test"; -import { - customThemeNames, - getGeneratedUniwindThemeOutputs, - renderDefaultThemeVariablesJSON, - renderUniwindThemesCSS, -} from "./generate-uniwind-themes.mts"; -import { readDefaultMobileThemeVariables } from "../src/lib/mobileTheme.test-support"; +import { customThemeNames, renderUniwindThemesCSS } from "./generate-uniwind-themes.mts"; describe("generate mobile Uniwind themes", () => { - it("keeps the committed outputs current", () => { - const staleOutputs = getGeneratedUniwindThemeOutputs() - .filter( - ([filename, contents]) => - !NodeFS.existsSync(filename) || NodeFS.readFileSync(filename, "utf8") !== contents, - ) - .map(([filename]) => NodePath.relative(import.meta.dirname, filename)); - - expect( - staleOutputs, - "Run `vp run --filter @t3tools/mobile generate` and commit the generated outputs.", - ).toEqual([]); - }); - - it("registers every custom palette for both appearances", () => { - expect(customThemeNames).toEqual([ - "t3-chat-light", - "t3-chat-dark", - "grove-light", - "grove-dark", - "ocean-light", - "ocean-dark", - "ember-light", - "ember-dark", - "iris-light", - "iris-dark", - ]); - - const stylesheet = renderUniwindThemesCSS(); - for (const themeName of customThemeNames) { - expect(stylesheet.match(new RegExp(`@variant ${themeName} \\{`, "gu"))).toHaveLength(1); - } - }); - - it("keeps the default runtime bridge and generated CSS on the same palette", () => { - const variables = JSON.parse(renderDefaultThemeVariablesJSON()); - - expect(variables.light).toEqual(readDefaultMobileThemeVariables("light")); - expect(variables.dark).toEqual(readDefaultMobileThemeVariables("dark")); - expect(variables.light["--color-screen"]).toBe("#fcfcfc"); - expect(variables.light["--color-drawer"]).toBe("#fafafa"); - expect(variables.dark["--color-screen"]).toBe("#0a0a0a"); - expect(variables.dark["--color-drawer"]).toBe("#000000"); - expect(Object.keys(variables.light)).toEqual(Object.keys(variables.dark)); - }); - it("gives every theme the same variables and a fixed Clerk palette for its appearance", () => { const css = NodeFS.readFileSync(NodePath.resolve(import.meta.dirname, "../global.css"), "utf8") + diff --git a/apps/mobile/scripts/notification-center-manager.test.ts b/apps/mobile/scripts/notification-center-manager.test.ts deleted file mode 100644 index e7a059496af6..000000000000 --- a/apps/mobile/scripts/notification-center-manager.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - Compiles and runs the native dependency regression directly. -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; -import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; - -// oxlint-disable-next-line t3code/no-global-process-runtime -- This native integration test uses the host Swift compiler. -describe.skipIf(NodeOS.platform() !== "darwin")( - "NotificationCenterManager native concurrency", - () => { - let directory: string; - let executable: string; - - beforeAll(() => { - directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-notifications-test-")); - executable = NodePath.join(directory, "notification-regression"); - const source = NodeFS.readFileSync( - new URL( - "../node_modules/expo-notifications/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift", - import.meta.url, - ), - "utf8", - ); - const manager = NodePath.join(directory, "NotificationCenterManager.swift"); - NodeFS.writeFileSync( - manager, - source.replace(/^import (ExpoModulesCore|UserNotifications)\n/gm, ""), - ); - NodeChildProcess.execFileSync( - "swiftc", - [ - "-swift-version", - "5", - "-sanitize=thread", - manager, - NodeURL.fileURLToPath( - new URL("./fixtures/NotificationCenterManagerRegression.swift", import.meta.url), - ), - "-o", - executable, - ], - { timeout: 30_000, encoding: "utf8" }, - ); - }); - - afterAll(() => { - if (directory) NodeFS.rmSync(directory, { recursive: true, force: true }); - }); - - it.each([ - ["reentrant", "allows callbacks to replace delegates without deadlocking"], - ["handoff", "delivers responses to delegates registering during delivery"], - ["pending", "retains new responses received while replaying pending responses"], - ["concurrent", "registers, removes, and broadcasts concurrently without data races"], - ])("%s: %s", (name) => { - const output = NodeChildProcess.execFileSync(executable, [name], { - encoding: "utf8", - timeout: 15_000, - }); - expect(output.trim()).toBe("passed"); - }); - }, -); diff --git a/apps/mobile/scripts/permissions-service.test.ts b/apps/mobile/scripts/permissions-service.test.ts deleted file mode 100644 index f1851723550a..000000000000 --- a/apps/mobile/scripts/permissions-service.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - Compiles the native dependency regression directly. -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodeModule from "node:module"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import { expect, it } from "vite-plus/test"; - -// oxlint-disable-next-line t3code/no-global-process-runtime -- This test compiles against the host Foundation framework. -it.skipIf(NodeOS.platform() !== "darwin")( - "registers and reads native permissions concurrently without corrupting the registry", - () => { - const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-permissions-test-")); - try { - const require = NodeModule.createRequire(import.meta.url); - const core = NodePath.dirname( - NodeModule.createRequire(require.resolve("expo/package.json")).resolve( - "expo-modules-core/package.json", - ), - ); - const read = (relativePath: string) => - NodeFS.readFileSync(NodePath.join(core, relativePath), "utf8").replace( - /^#import .*$/gm, - "", - ); - const source = NodePath.join(directory, "regression.m"); - NodeFS.writeFileSync( - source, - [ - "#import ", - "#import ", - "#include ", - "#include ", - "typedef void (^EXPromiseResolveBlock)(id result);", - "typedef void (^EXPromiseRejectBlock)(NSString *, NSString *, NSError *);", - "#define RCTLogWarn(...)", - read("ios/Interfaces/Permissions/EXPermissionsInterface.h"), - read("ios/Legacy/Services/Permissions/EXPermissionsService.h"), - read("ios/Legacy/Services/Permissions/EXPermissionsService.m"), - NodeFS.readFileSync( - new URL("./fixtures/PermissionsServiceRegression.m", import.meta.url), - "utf8", - ), - ].join("\n"), - ); - const executable = NodePath.join(directory, "regression"); - NodeChildProcess.execFileSync( - "clang", - [ - "-fobjc-arc", - "-fblocks", - "-fsanitize=thread", - "-framework", - "Foundation", - source, - "-o", - executable, - ], - { encoding: "utf8", timeout: 30_000 }, - ); - expect( - NodeChildProcess.execFileSync(executable, { encoding: "utf8", timeout: 15_000 }).trim(), - ).toBe("passed"); - } finally { - NodeFS.rmSync(directory, { recursive: true, force: true }); - } - }, -); diff --git a/apps/mobile/scripts/sync-context-chip-icons.mjs b/apps/mobile/scripts/sync-context-chip-icons.mjs new file mode 100644 index 000000000000..aac4983d75e2 --- /dev/null +++ b/apps/mobile/scripts/sync-context-chip-icons.mjs @@ -0,0 +1,93 @@ +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const scriptDirectory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const appDirectory = NodePath.resolve(scriptDirectory, ".."); +const repositoryRoot = NodePath.resolve(appDirectory, "../.."); +const outputDirectory = NodePath.join(appDirectory, "assets/context-chip-icons"); +const generatedModulePath = NodePath.join(appDirectory, "src/lib/contextChipIcons.generated.ts"); + +const requireFromWeb = NodeModule.createRequire( + NodePath.join(repositoryRoot, "apps/web/package.json"), +); +const requireFromScripts = NodeModule.createRequire( + NodePath.join(repositoryRoot, "scripts/package.json"), +); +const sharp = requireFromScripts("sharp"); +const lucideIconsDirectory = NodePath.join( + NodePath.dirname(requireFromWeb.resolve("lucide-react/package.json")), + "dist/esm/icons", +); +const webChipSource = NodeFS.readFileSync( + NodePath.join(repositoryRoot, "apps/web/src/components/composerInlineChip.ts"), + "utf8", +); +const skillSvg = webChipSource.match(/SKILL_CHIP_ICON_SVG = `([\s\S]*?)`;/)?.[1]; + +if (!skillSvg) { + throw new Error( + "Could not read SKILL_CHIP_ICON_SVG from apps/web/src/components/composerInlineChip.ts", + ); +} + +const lucideIcons = [ + "circle-dashed", + "film", + "git-merge", + "git-pull-request-arrow", + "git-pull-request-closed", + "git-pull-request-draft", + "image", + "message-circle", + "messages-square", + "mouse-pointer-click", + "terminal", +]; + +async function lucideSvg(name) { + const module = await import( + NodeURL.pathToFileURL(NodePath.join(lucideIconsDirectory, `${name}.js`)).href + ); + const body = module.__iconNode + .map(([tag, { key: _key, ...attributes }]) => { + const attributeText = Object.entries(attributes) + .map(([attribute, value]) => `${attribute}="${value}"`) + .join(" "); + return `<${tag} ${attributeText}/>`; + }) + .join(""); + return `${body}`; +} + +async function renderIcon(token, svg) { + const sized = svg + .replace(/^]*?>/, (tag) => + tag + .replace(/ (?:width|height)="[^"]*"/g, "") + .replace(" + ` ${/^\w+$/.test(token) ? token : `"${token}"`}: require("../../assets/context-chip-icons/${token}.png"),`, + ) + .join("\n")}\n} as const satisfies Readonly>;\n`; +NodeFS.writeFileSync(generatedModulePath, generatedSource); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 7a729c76dfad..819f780ed7d3 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -6,7 +6,7 @@ import { StatusBar } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; -import { createStaticNavigation } from "@react-navigation/native"; +import { createStaticNavigation, getStateFromPath } from "@react-navigation/native"; import { RegistryContext } from "@effect/atom-react"; import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet"; @@ -20,8 +20,11 @@ import { } from "./features/settings/appearance/AppearancePreferencesProvider"; import { RootStack } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; +import { subscribeComposerDraftModelReconciliation } from "./state/use-composer-drafts"; +import { environmentThreadShells } from "./state/threads"; +import { environmentShell } from "./state/shell"; import { OverlayPortalHost } from "./components/ui/OverlayPortal"; -import { shouldHandleAppLink } from "./lib/appLinking"; +import { shouldHandleAppLink, restoreDraftLinkState } from "./lib/appLinking"; import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; @@ -41,6 +44,8 @@ const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], config: { initialRouteName: "Home" }, filter: shouldHandleAppLink, + getStateFromPath: (path: string, options: Parameters[1]) => + restoreDraftLinkState(getStateFromPath(path, options), path), }; const Navigation = createStaticNavigation(RootStack); @@ -57,6 +62,16 @@ function SplashScreenCoordinator() { export default function App() { useUiRuntimeMemoryWarningGc(); + useEffect( + () => + subscribeComposerDraftModelReconciliation({ + registry: appAtomRegistry, + threadShellAtom: environmentThreadShells.threadShellAtom, + threadRefsAtom: environmentThreadShells.threadRefsAtom, + shellStateAtom: environmentShell.stateAtom, + }), + [], + ); return ( diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index c35d9911ca41..d5a8a038fe43 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1,11 +1,9 @@ +import { isDraftThreadRoute } from "./features/threads/thread-route-state"; import { ComponentCatalogScreen } from "./features/development/ComponentCatalogScreen"; import { PullRequestGuideStopScreen } from "./features/pull-requests/guide/PullRequestGuideScreen"; import { PullRequestInlineCommentScreen } from "./features/pull-requests/PullRequestInlineCommentScreen"; import { PullRequestDiscussionScreen } from "./features/pull-requests/PullRequestDiscussionScreen"; -import { - createV5StackNavigator as createNativeStackNavigator, - createV5SheetStackNavigator, -} from "./native/createV5StackNavigator"; +import { createV5SheetStackNavigator } from "./native/createV5StackNavigator"; import { createWorkspaceStackNavigator } from "./features/layout/createWorkspaceStackNavigator"; import { createPathConfigForStaticNavigation, @@ -19,26 +17,21 @@ import { type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; import { useEffect, useRef, type ReactNode } from "react"; -import { - Platform, - Pressable, - ScrollView, - StyleSheet, - View, - useWindowDimensions, -} from "react-native"; +import { Platform, Pressable, ScrollView, StyleSheet, useWindowDimensions } from "react-native"; import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/ui/AppText"; import { androidWorkspaceDestinationAnimation } from "./lib/android-workspace-navigation"; import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; +import { WorkspaceConnectionStatusProvider } from "./features/home/WorkspaceConnectionTitle"; import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen"; +import { AndroidWorkspaceSettings } from "./features/workspace/AndroidWorkspaceSettings"; import { AndroidWorkspaceTabsProvider } from "./features/workspace/AndroidWorkspaceTabsProvider"; import { PullRequestRouteScreen, PullRequestFileRouteScreen, - PullRequestFilesRouteScreen, } from "./features/pull-requests/PullRequestRouteScreen"; +import { PullRequestFilesSheet } from "./features/pull-requests/PullRequestFiles"; import { PullRequestReviewSheet } from "./features/pull-requests/PullRequestReviewSheet"; import { PullRequestReviewersScreen } from "./features/pull-requests/PullRequestReviewersScreen"; import { @@ -69,6 +62,7 @@ import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet"; import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet"; import { ThreadPullRequestsSheet } from "./features/threads/ThreadPullRequestsSheet"; import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet"; +import { ThreadBackgroundWorkSheet } from "./features/threads/ThreadBackgroundWorkSheet"; import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl"; import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; import { McpAppFullscreenScreen } from "./features/threads/McpAppFullscreenScreen"; @@ -79,7 +73,6 @@ import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; import { AddProjectNewRoute } from "./features/projects/AddProjectNewRoute"; import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; -import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; import { NewTaskBranchPickerRouteScreen, NewTaskEnvironmentPickerRouteScreen, @@ -146,7 +139,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { deriveLayout } from "./lib/layout"; import { useNativeLayoutMetrics } from "./native/native-layout-metrics"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; -import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; +import { HORIZONTAL_FORM_SHEET_OPTIONS, formSheetDetents } from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads"; @@ -169,7 +162,9 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, }; -const PULL_REQUEST_HEADER_OPTIONS: AppScreenOptions = { +// Hiding Android's native header after mount can leave detail panes measured short. +// These screens render their own Material toolbar, so hide it from the first layout. +const DETAIL_HEADER_OPTIONS: AppScreenOptions = { ...GLASS_HEADER_OPTIONS, headerShown: Platform.OS !== "android", }; @@ -205,6 +200,15 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { const NESTED_NAVIGATOR_OPTIONS = { nativeContentInsetHorizontally: false, headerShown: false }; +// Thread settings keep the Duo's vertical bar so cancel, search, filter and confirm sit in the +// rail. The picker stack spans the sheet so its header reaches the rail, and insets its content. +const THREAD_SETTINGS_SHEET_OPTIONS = { + presentation: "formSheet" as const, + sheetAllowedDetents: [1], + sheetGrabberVisible: true, + nativeContentInsetHorizontally: false, +}; + const SettingsContentStack = createV5SheetStackNavigator({ initialRouteName: "Settings", screenOptions: { @@ -217,6 +221,9 @@ const SettingsContentStack = createV5SheetStackNavigator({ linking: "", options: { title: "Settings", + // Android draws its own app bar here. Hidden only from inside the screen, the native one + // showed for the first frame of the Settings tab and pushed the page down. + headerShown: Platform.OS !== "android", }, }), SettingsEnvironments: createNativeStackScreen({ @@ -360,9 +367,7 @@ const SettingsContentStack = createV5SheetStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [1], - sheetGrabberVisible: true, + ...THREAD_SETTINGS_SHEET_OPTIONS, }), }, }), @@ -446,97 +451,6 @@ const SettingsSheetStack = createV5SheetStackNavigator({ // across two bars the whole screen slides). const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId"; -const NewTaskSheetStack = createNativeStackNavigator({ - initialRouteName: "NewTask", - screenOptions: { - ...SHEET_GLASS_HEADER_OPTIONS, - contentStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined, - // UIKit's default push adds a dimming shadow and independently transitions - // the navigation bar. Both read as mismatched sheet backgrounds here. - animation: Platform.OS === "ios" ? "simple_push" : undefined, - animationDuration: Platform.OS === "ios" ? 350 : undefined, - }, - screens: { - NewTask: createNativeStackScreen({ - screen: NewTaskRouteScreen, - linking: "", - options: { - title: "Choose project", - }, - }), - NewTaskDraft: createNativeStackScreen({ - screen: NewTaskDraftRouteScreen, - linking: "draft", - options: { - headerBackVisible: false, - title: "", - }, - }), - NewTaskEnvironment: createNativeStackScreen({ - screen: NewTaskEnvironmentPickerRouteScreen, - linking: "draft/environment", - options: { - title: "Environment", - }, - }), - NewTaskBranch: createNativeStackScreen({ - screen: NewTaskBranchPickerRouteScreen, - linking: "draft/branch", - options: { - title: "Branch", - }, - }), - NewTaskFile: createNativeStackScreen({ - screen: ThreadFileScreen, - linking: "draft/files/:path*", - options: SOLID_HEADER_OPTIONS, - }), - NewTaskAttachment: createNativeStackScreen({ - screen: AttachmentFileScreen, - linking: "draft/attachments/:attachmentId", - options: SOLID_HEADER_OPTIONS, - }), - ThreadSettings: createNativeStackScreen({ - screen: NewTaskThreadSettingsRouteScreen, - linking: "draft/settings", - options: { - gestureEnabled: true, - headerShown: false, - ...(Platform.OS === "android" - ? { presentation: "card" as const } - : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [1], - sheetGrabberVisible: true, - }), - }, - }), - AddProject: createNativeStackScreen({ - screen: AddProjectSourceRoute, - linking: "add-project", - options: { - title: "Add Project", - }, - }), - AddProjectRepository: createNativeStackScreen({ - screen: AddProjectRepositoryRoute, - linking: "add-project/repository", - }), - AddProjectDestination: createNativeStackScreen({ - screen: AddProjectDestinationRoute, - linking: "add-project/destination", - }), - AddProjectLocal: createNativeStackScreen({ - screen: AddProjectLocalRoute, - linking: "add-project/local", - }), - AddProjectNew: createNativeStackScreen({ - screen: AddProjectNewRoute, - linking: "add-project/new", - }), - }, -}); - const WORKSPACE_OVERLAY_ROUTES = new Set([ "PullRequestReview", "PullRequestInlineComment", @@ -547,10 +461,22 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ "GitCommit", "GitConfirm", "GitOverview", - "NewTaskSheet", + "NewTaskProject", + "NewTaskFiles", + "NewTaskFile", + "NewTaskAttachment", + "NewTaskThreadSettings", + "AddProject", + "AddProjectRepository", + "AddProjectDestination", + "AddProjectLocal", + "AddProjectNew", + "NewTaskEnvironment", + "NewTaskBranch", "SettingsLegal", "SettingsSheet", "ThreadAgents", + "ThreadBackgroundWork", "ThreadPullRequests", "ThreadQueue", "ThreadReviewComment", @@ -560,7 +486,12 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ ]); function workspaceLocationFromState(state: NavigationState) { - const routes = state.routes.filter((route) => !WORKSPACE_OVERLAY_ROUTES.has(route.name)); + // Android opens Settings as a workspace page rather than an overlay, so the layout sees it. + const routes = state.routes.filter( + (route) => + !WORKSPACE_OVERLAY_ROUTES.has(route.name) || + (Platform.OS === "android" && route.name === "SettingsSheet"), + ); const effectiveState = routes.length > 0 && routes.length !== state.routes.length ? ({ ...state, routes, index: routes.length - 1 } as NavigationState) @@ -592,42 +523,58 @@ function RootStackLayout(props: { const path = getPathFromState(props.state, navigationPathConfig); const pathname = path.startsWith("/") ? path : `/${path}`; const workspaceLocation = workspaceLocationFromState(props.state); + const draftRoute = props.state.routes.findLast((route) => route.name === "Thread"); + const draftParams = ( + draftRoute?.params as + | { draft?: { draftId?: string | string[]; pendingTaskId?: string | string[] } } + | undefined + )?.draft; + const routeDraftId = Array.isArray(draftParams?.draftId) + ? draftParams.draftId[0] + : draftParams?.draftId; + const routePendingTaskId = Array.isArray(draftParams?.pendingTaskId) + ? draftParams.pendingTaskId[0] + : draftParams?.pendingTaskId; useAgentNotificationNavigation(workspaceLocation.pathname); useConnectOnboardingNavigation(); useAppShortcuts(props.state); useEffect(() => { - const topRouteName = props.state.routes[props.state.index]?.name; const transition = transitionIncomingSharePresentation(sharePresentationRef.current, { - isShareSheetPresented: topRouteName === "NewTaskSheet", + isShareSheetPresented: isDraftThreadRoute(props.state.routes[props.state.index]), pendingShareId: pendingShare?.id ?? null, }); sharePresentationRef.current = transition.state; if (!transition.shareIdToPresent) { return; } - navigation.navigate("NewTaskSheet", { - screen: "NewTask", - params: { incomingShareId: transition.shareIdToPresent }, - }); + navigation.navigate("Thread", { draft: { incomingShareId: transition.shareIdToPresent } }); }, [navigation, pendingShare, props.state]); return ( - - - - - - - {props.children} - - - - - + + + + + + + + + {props.children} + + + + + + + ); } @@ -704,64 +651,134 @@ const RootStackConfig = createWorkspaceStackNavigator({ path: "pull-requests/:environmentId/:projectId/:repository/:number", parse: { number: Number }, }, - options: PULL_REQUEST_HEADER_OPTIONS, + options: DETAIL_HEADER_OPTIONS, }), PullRequestGuideStop: createNativeStackScreen({ screen: PullRequestGuideStopScreen, - options: PULL_REQUEST_HEADER_OPTIONS, + options: DETAIL_HEADER_OPTIONS, }), PullRequestFile: createNativeStackScreen({ screen: PullRequestFileRouteScreen, - options: PULL_REQUEST_HEADER_OPTIONS, + options: DETAIL_HEADER_OPTIONS, }), PullRequestFiles: createNativeStackScreen({ - screen: PullRequestFilesRouteScreen, + screen: PullRequestFilesSheet, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.6, 0.92], + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.6), sheetGrabberVisible: true, - headerShown: false, + sheetCornerRadius: Platform.OS === "android" ? 28 : undefined, }, }), PullRequestReviewers: createNativeStackScreen({ screen: PullRequestReviewersScreen, options: { - ...PULL_REQUEST_HEADER_OPTIONS, - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.75, 0.92], + ...DETAIL_HEADER_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.75), sheetGrabberVisible: true, headerBackVisible: false, }, }), PullRequestDiscussion: createNativeStackScreen({ screen: PullRequestDiscussionScreen, - options: PULL_REQUEST_HEADER_OPTIONS, + options: DETAIL_HEADER_OPTIONS, }), PullRequestInlineComment: createNativeStackScreen({ screen: PullRequestInlineCommentScreen, options: { ...(Platform.OS === "android" ? { presentation: "fullScreenModal" as const } - : FORM_SHEET_PRESENTATION_OPTIONS), - sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.75, 0.92], + : { ...SHEET_GLASS_HEADER_OPTIONS, ...HORIZONTAL_FORM_SHEET_OPTIONS }), + sheetAllowedDetents: Platform.OS === "android" ? undefined : formSheetDetents(0.75), sheetGrabberVisible: Platform.OS !== "android", - headerShown: false, }, }), PullRequestReview: createNativeStackScreen({ screen: PullRequestReviewSheet, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.75, 0.92], + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.75), sheetGrabberVisible: true, - headerShown: false, }, }), Thread: createNativeStackScreen({ screen: ThreadRouteScreen, - linking: THREAD_LINKING_PREFIX, + linking: { path: "threads/:environmentId?/:threadId?", alias: ["new/draft"] }, + options: DETAIL_HEADER_OPTIONS, + }), + NewTaskProject: createNativeStackScreen({ + screen: NewTaskRouteScreen, + linking: { path: "new", alias: ["new/project"] }, + options: { ...GLASS_HEADER_OPTIONS, title: "Choose project" }, + }), + NewTaskEnvironment: createNativeStackScreen({ + linking: "new/draft/environment", + screen: NewTaskEnvironmentPickerRouteScreen, + options: { ...GLASS_HEADER_OPTIONS, title: "Environment" }, + }), + NewTaskBranch: createNativeStackScreen({ + linking: "new/draft/branch", + screen: NewTaskBranchPickerRouteScreen, + options: { ...GLASS_HEADER_OPTIONS, title: "Branch" }, + }), + AddProject: createNativeStackScreen({ + linking: "new/add-project", + screen: AddProjectSourceRoute, + + options: { + ...GLASS_HEADER_OPTIONS, + title: "Add project", + }, + }), + AddProjectRepository: createNativeStackScreen({ + linking: "new/add-project/repository", + screen: AddProjectRepositoryRoute, + options: GLASS_HEADER_OPTIONS, + }), + AddProjectDestination: createNativeStackScreen({ + linking: "new/add-project/destination", + screen: AddProjectDestinationRoute, + options: GLASS_HEADER_OPTIONS, + }), + AddProjectLocal: createNativeStackScreen({ + linking: "new/add-project/local", + screen: AddProjectLocalRoute, options: GLASS_HEADER_OPTIONS, }), + AddProjectNew: createNativeStackScreen({ + linking: "new/add-project/new", + screen: AddProjectNewRoute, + options: GLASS_HEADER_OPTIONS, + }), + NewTaskFiles: createNativeStackScreen({ + linking: "new/draft/files", + screen: ThreadFilesTreeScreen, + options: SOLID_HEADER_OPTIONS, + }), + NewTaskFile: createNativeStackScreen({ + linking: "new/draft/files/:path*", + screen: ThreadFileScreen, + options: SOLID_HEADER_OPTIONS, + }), + NewTaskAttachment: createNativeStackScreen({ + screen: AttachmentFileScreen, + linking: "new/draft/attachments/:attachmentId", + options: SOLID_HEADER_OPTIONS, + }), + NewTaskThreadSettings: createNativeStackScreen({ + screen: NewTaskThreadSettingsRouteScreen, + linking: "new/draft/settings", + options: { + gestureEnabled: true, + headerShown: false, + ...(Platform.OS === "android" + ? { presentation: "card" as const } + : THREAD_SETTINGS_SHEET_OPTIONS), + }, + }), ThreadTerminal: createNativeStackScreen({ screen: ThreadTerminalRouteScreen, linking: `${THREAD_LINKING_PREFIX}/terminal`, @@ -800,8 +817,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ // formSheet; use a full-screen modal there instead. ...(Platform.OS === "android" ? { presentation: "fullScreenModal" as const } - : FORM_SHEET_PRESENTATION_OPTIONS), - sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92], + : HORIZONTAL_FORM_SHEET_OPTIONS), + sheetAllowedDetents: Platform.OS === "android" ? undefined : formSheetDetents(0.55), sheetGrabberVisible: Platform.OS !== "android", }, }), @@ -840,27 +857,34 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [1], - sheetGrabberVisible: true, + ...THREAD_SETTINGS_SHEET_OPTIONS, }), }, }), ThreadQueue: createNativeStackScreen({ screen: ThreadQueueSheet, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, + headerShown: false, + sheetAllowedDetents: formSheetDetents(0.65, 0.95), + sheetGrabberVisible: true, + }, + }), + ThreadBackgroundWork: createNativeStackScreen({ + screen: ThreadBackgroundWorkSheet, + options: { + ...HORIZONTAL_FORM_SHEET_OPTIONS, headerShown: false, - sheetAllowedDetents: [0.65, 0.95], + sheetAllowedDetents: formSheetDetents(0.5, 0.9), sheetGrabberVisible: true, }, }), ThreadAgents: createNativeStackScreen({ screen: ThreadAgentsSheet, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, headerShown: false, - sheetAllowedDetents: [0.5, 0.9], + sheetAllowedDetents: formSheetDetents(0.5, 0.9), sheetGrabberVisible: true, }, }), @@ -868,8 +892,9 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: GitOverviewSheet, linking: `${THREAD_LINKING_PREFIX}/git`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.55, 0.92], + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.55), sheetGrabberVisible: true, }, }), @@ -877,8 +902,9 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: ThreadPullRequestsSheet, linking: `${THREAD_LINKING_PREFIX}/pull-requests`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.55, 0.92], + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.55), sheetGrabberVisible: true, }, }), @@ -886,8 +912,9 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: GitCommitSheet, linking: `${THREAD_LINKING_PREFIX}/git/commit`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.55, 0.92], + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.8), sheetGrabberVisible: true, }, }), @@ -895,8 +922,9 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: GitBranchesSheet, linking: `${THREAD_LINKING_PREFIX}/git/branches`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.55, 0.92], + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.55), sheetGrabberVisible: true, }, }), @@ -904,18 +932,21 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: GitConfirmSheet, linking: `${THREAD_LINKING_PREFIX}/git-confirm`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.45, 0.7], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }, }), SettingsSheet: createNativeStackScreen({ screen: SettingsSheetStack, + layout: ({ children }) => {children}, linking: "settings", options: { ...NESTED_NAVIGATOR_OPTIONS, gestureEnabled: true, headerShown: false, + // Android opens Settings as a top-level tab, so it swaps like the other tabs. + ...(Platform.OS === "android" ? { animation: "none" as const } : null), }, }), SettingsLegal: createNativeStackScreen({ @@ -935,8 +966,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS), title: "Set up T3 Connect", gestureEnabled: true, - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.6, 0.95], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.6, 0.95), sheetGrabberVisible: true, }, }), @@ -948,7 +979,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const, headerShown: false } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }), @@ -962,28 +993,11 @@ const RootStackConfig = createWorkspaceStackNavigator({ // native header stack mounts without remounting the pairing form. ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), title: "Add Environment", - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }, }), - NewTaskSheet: createNativeStackScreen({ - screen: NewTaskSheetStack, - linking: "new", - // A screen's layout replaces the navigator's screenLayout. - layout: ({ children, route }) => ( - - - {children} - - - ), - options: { - ...NESTED_NAVIGATOR_OPTIONS, - gestureEnabled: true, - headerShown: false, - }, - }), NotFound: createNativeStackScreen({ screen: NotFoundScreen, linking: "*", @@ -1023,22 +1037,38 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig const nativeMetrics = useNativeLayoutMetrics(); const usesSplitView = deriveLayout({ width, height, nativeMetrics }).usesSplitView; const usesWorkspaceFlowScreens = Platform.OS === "android" || usesSplitView; + const flowPresentations = useRef(new Map()); return ( } + screenListeners={{ + state: ({ data }) => { + const keys = new Set(data.state.routes.map((route) => route.key)); + for (const key of flowPresentations.current.keys()) { + if (!keys.has(key)) flowPresentations.current.delete(key); + } + }, + }} screenOptions={({ route }) => { - if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") { + if (route.name !== "SettingsSheet") { return Platform.OS === "android" ? { animation: androidWorkspaceDestinationAnimation(route.name, usesSplitView) } : {}; } - return usesWorkspaceFlowScreens + // Moving an open flow between a sheet and a split column remounts its nested + // navigator. Keep its presentation until dismissal so folding preserves the settings flow. + let usesCard = flowPresentations.current.get(route.key); + if (usesCard === undefined) { + usesCard = usesWorkspaceFlowScreens; + flowPresentations.current.set(route.key, usesCard); + } + return usesCard ? { presentation: "card" } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.92], + presentation: "formSheet", + sheetAllowedDetents: [1], sheetGrabberVisible: true, }; }} diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 9cd2d85cef57..ebbfbe988147 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -347,6 +347,8 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { onPressDocument={props.onPressDocument} /> ) : null} - void share()} - className="rounded-xl bg-subtle p-4" - > - - {sharing ? "Opening attachment…" : "Open or share attachment"} - - + fullWidth + loading={sharing} + /> {previewOpen && record.kind === "image" && uri ? ( {props.onOpenThread ? ( - props.onOpenThread?.({ environmentId: record.environmentId, threadId: record.threadId, }) } - className="rounded-xl bg-subtle p-4" - > - Open thread - + fullWidth + /> ) : null} ) : null} @@ -322,13 +322,12 @@ export function ComposerContextSheet(props: { value={props.skillDescription ?? "No description is available for this skill."} /> {props.onOpenSkill ? ( - - View instructions - + fullWidth + /> ) : null} ) : null} @@ -364,31 +363,23 @@ export function ComposerContextSheet(props: { ) : null} {props.onOpenAttachment ? ( - - Open attachment - + fullWidth + /> ) : null} {props.onOpenPullRequest && !pullRequestUrl ? ( - - Open pull request - + fullWidth + /> ) : null} {props.onRemove ? ( - - Remove from draft - + ) : null} diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index a49fa6ef391b..f68c5396d841 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -34,6 +34,7 @@ import { export type ComposerEditorProps = NativeComposerEditorProps & { readonly draftKey?: string | null; + readonly hidden?: boolean; readonly environmentId?: EnvironmentId; readonly onOpenMention?: (path: string) => void; readonly onOpenAttachment?: (attachment: ComposerDocumentAttachment) => void; @@ -43,6 +44,7 @@ export type ComposerEditorProps = NativeComposerEditorProps & { export function ComposerEditor({ draftKey, + hidden, environmentId, onOpenMention, onOpenAttachment, @@ -95,6 +97,7 @@ export function ComposerEditor({ retained.attachments.length, controller.signal, retained.context?.records.length ?? 0, + environmentId, ); if (!result) { insertComposerDraftText(draftKey, clipboard.text, insertion); @@ -107,10 +110,19 @@ export function ComposerEditor({ ); return; } + const warnings: string[] = []; if (result.failures.length > 0) + warnings.push( + "Some attachments could not be copied. Reconnect to the source environment and copy them again.", + ); + if (result.skippedThreads.length > 0) + warnings.push( + `${result.skippedThreads.map((title) => `"${title}"`).join(", ")} ${result.skippedThreads.length === 1 ? "is" : "are"} on another machine.`, + ); + if (warnings.length > 0) Dialog.alert( - "Some attachments could not be copied", - "Reconnect to the source environment and copy them again. References without their files are marked unavailable.", + "Some context is unavailable", + [...warnings, "Those references are marked unavailable."].join("\n\n"), ); }); setComposerContextImporting(draftKey, false); @@ -155,7 +167,9 @@ export function ComposerEditor({ ); return ( <> + {/* Reset revision history with the recreated iOS view without aborting context imports. */} void; +}) { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: props.modelOption?.capabilities, + selections: props.selection?.options, + }); + const promptTraits = getPromptControlledTraits(descriptors, props.prompt); + const summary = props.provider + ? buildTraitsTriggerDisplay({ + provider: props.provider, + descriptors, + primarySelectDescriptorId: promptTraits.primarySelectDescriptor?.id ?? null, + ultrathinkPromptControlled: promptTraits.ultrathinkPromptControlled, + modelSelection: props.selection, + reportedModelSelection: props.reportedSelection, + }).label + : ""; + const badge = props.modelOption?.providerBadge; + const label = props.modelOption?.label ?? props.selection?.model ?? "Choose model"; + return ( + + badge?.showBadge ? ( + + ) : ( + + ) + } + label={label} + iconOnly={props.iconOnly} + suffix={ + summary ? ( + + {summary} + + ) : undefined + } + maxWidth="100%" + onPress={props.onPress} + /> + ); +} diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index d6bbf9d99294..74708eff8d8c 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -1,7 +1,6 @@ import type { ComponentProps, ReactNode } from "react"; import { useCallback, useMemo, useState } from "react"; import { - Platform, Pressable, ScrollView, View, @@ -13,6 +12,8 @@ import { type ViewStyle, } from "react-native"; +import Animated, { FadeIn } from "react-native-reanimated"; + import { cn } from "../lib/cn"; import { AppText as Text } from "./ui/AppText"; import { SymbolView } from "./ui/AppSymbol"; @@ -21,8 +22,9 @@ import { useAndroidControlSizing } from "./ui/useAndroidControlSizing"; const COMPOSER_TOOLBAR_GAP = 8; const COMPOSER_TOOLBAR_FADE_WIDTH = 18; const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4; - +const INLINE_CONTROL_LABEL_ENTERING = FadeIn.duration(160); export function ComposerInlineControl(props: { + readonly size?: "regular" | "large"; readonly accessibilityHint?: string; readonly accessibilityLabel?: string; readonly disabled?: boolean; @@ -30,6 +32,9 @@ export function ComposerInlineControl(props: { readonly icon?: ComponentProps["name"]; readonly renderIcon?: (size: number) => ReactNode; readonly label: string; + readonly suffix?: ReactNode; + /** Show only the icon and chevron; the label still names the control for accessibility. */ + readonly iconOnly?: boolean; readonly maxWidth?: ViewStyle["maxWidth"]; readonly onPress?: () => void; readonly selected?: boolean; @@ -38,56 +43,76 @@ export function ComposerInlineControl(props: { readonly showChevron?: boolean; }) { const { scale, smallIconSize } = useAndroidControlSizing(); + // Keep the picker anchored to its parent while the editor changes height. + const animatesLabel = props.iconOnly !== undefined; + const iconSize = props.size === "large" ? 20 : smallIconSize; return ( - - {props.renderIcon ? ( - - {props.renderIcon(smallIconSize)} - - ) : props.icon ? ( - - ) : null} - - {props.label} - - {props.showChevron === false ? null : ( - - )} - + {props.renderIcon ? ( + + {props.renderIcon(iconSize)} + + ) : props.icon ? ( + + ) : null} + {props.iconOnly ? null : ( + + + {props.label} + + + )} + {!props.iconOnly && props.suffix ? ( + {props.suffix} + ) : null} + {props.showChevron === false ? null : ( + + )} + + ); } @@ -219,6 +244,7 @@ export function ComposerToolbarScroller(props: { } export function ComposerActionButton(props: { + readonly size?: "regular" | "large"; readonly accessibilityLabel: string; readonly disabled?: boolean; readonly icon: ComponentProps["name"]; @@ -230,13 +256,14 @@ export function ComposerActionButton(props: { readonly onTouchStart?: PressableProps["onTouchStart"]; }) { const { scale, smallIconSize } = useAndroidControlSizing(); - const circleSize = Math.round(30 * scale); + const circleSize = Math.round((props.size === "large" ? 44 : 30) * scale); return ( {name ?? "Document"} - - Cancel - + + + diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index c087b9dcfc88..c7064e522a99 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,7 +1,11 @@ import { Image } from "expo-image"; import { Defs, LinearGradient, Path, Stop, Svg } from "react-native-svg"; import { View } from "react-native"; -import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display"; +import { + normalizeProviderAccentColor, + providerAccentForegroundColor, + providerInstanceInitials, +} from "@t3tools/client-runtime/state/provider-instance-display"; import { useId, useState } from "react"; import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; @@ -156,6 +160,7 @@ export function ProviderInstanceIcon(props: { readonly showBadge?: boolean; readonly surfaceColor: string; }) { + const accentColor = normalizeProviderAccentColor(props.accentColor); return ( @@ -169,7 +174,7 @@ export function ProviderInstanceIcon(props: { style={{ position: "absolute", right: -3, bottom: -3, width: 32, alignItems: "flex-end" }} > {providerInstanceInitials(props.displayName)} diff --git a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx b/apps/mobile/src/components/ui/AndroidScreenHeader.tsx index b35c7cc58f8f..1b259f1ecbfa 100644 --- a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/ui/AndroidScreenHeader.tsx @@ -1,5 +1,6 @@ import { AppActivityIndicator } from "./AppActivityIndicator"; -import { Column, Text as NativeText, RNHostView } from "@expo/ui/jetpack-compose"; +import { Column, Row, Text as NativeText, RNHostView } from "@expo/ui/jetpack-compose"; +import { weight } from "@expo/ui/jetpack-compose/modifiers"; import { useState, type ReactNode } from "react"; import { View } from "react-native"; @@ -43,6 +44,8 @@ export function AndroidScreenHeader(props: { readonly onBack?: () => void; readonly embedded?: boolean; readonly hideBottomBorder?: boolean; + /** One row with the subtitle after the title, for screens short on height. */ + readonly compact?: boolean; }) { const { colors } = useControlTheme(); const titleTypography = useScaledTextRole("title"); @@ -65,6 +68,7 @@ export function AndroidScreenHeader(props: { }} > @@ -95,6 +99,26 @@ export function AndroidScreenHeader(props: { > {props.title} + ) : props.compact ? ( + + + {props.title} + + + {props.subtitle} + + ) : ( : undefined} hideBottomBorder={props.hideBottomBorder} + compact={props.compact} actions={ search ? [ diff --git a/apps/mobile/src/components/ui/AppBar.types.ts b/apps/mobile/src/components/ui/AppBar.types.ts index 621006d949c0..e3db494219d7 100644 --- a/apps/mobile/src/components/ui/AppBar.types.ts +++ b/apps/mobile/src/components/ui/AppBar.types.ts @@ -68,6 +68,8 @@ export interface AppBarProps { readonly onPress?: () => void; }; readonly hideBottomBorder?: boolean; + /** Android: one title row with the subtitle inline. iOS ignores it. */ + readonly compact?: boolean; readonly trailing?: ReactNode; readonly matchSearchSurface?: boolean; readonly options?: AppNativeStackNavigationOptions; diff --git a/apps/mobile/src/components/ui/AppSymbol.tsx b/apps/mobile/src/components/ui/AppSymbol.tsx index 7e5e95da3beb..16a0f55d0a7f 100644 --- a/apps/mobile/src/components/ui/AppSymbol.tsx +++ b/apps/mobile/src/components/ui/AppSymbol.tsx @@ -9,6 +9,7 @@ import IconAlertCircle from "@tabler/icons-react-native/IconAlertCircle"; import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle"; import IconApps from "@tabler/icons-react-native/IconApps"; import IconArchive from "@tabler/icons-react-native/IconArchive"; +import IconCalendarClock from "@tabler/icons-react-native/IconCalendarClock"; import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp"; import IconArrowDown from "@tabler/icons-react-native/IconArrowDown"; import IconArrowForwardUp from "@tabler/icons-react-native/IconArrowForwardUp"; @@ -178,6 +179,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "circle.dashed": IconCircleDashed, "circle.inset.filled": IconCircleDot, "circle.dashed.inset.filled": IconCircleDot, + "calendar.badge.clock": IconCalendarClock, clock: IconClock, timer: IconClock, ticket: IconTicket, diff --git a/apps/mobile/src/components/ui/Button.android.tsx b/apps/mobile/src/components/ui/Button.android.tsx index af8ce1aba1a2..ac917427d1c6 100644 --- a/apps/mobile/src/components/ui/Button.android.tsx +++ b/apps/mobile/src/components/ui/Button.android.tsx @@ -49,7 +49,7 @@ export function Button(props: ButtonProps) { void; readonly disabled?: boolean; @@ -29,7 +30,7 @@ export function Button(props: ButtonProps) { return ( { + const Slot = (props: { readonly children?: ReactNode }) =>
{props.children}
; + const Button = (props: { readonly children?: ReactNode; readonly onClick?: () => void }) => ( + + ); + return { + AlertDialog: Object.assign(Slot, { + Title: Slot, + Text: Slot, + DismissButton: Slot, + ConfirmButton: Slot, + }), + Button, + OutlinedButton: Button, + Column: Slot, + FlowRow: Slot, + Icon: () => null, + OutlinedTextField: Object.assign(Slot, { Label: Slot, Placeholder: Slot }), + Text: Slot, + useNativeState: (value: unknown) => ({ get: () => value }), + }; +}); +vi.mock("./NativeThemeHost.android", () => ({ + NativeThemeHost: (props: { readonly children?: ReactNode }) =>
{props.children}
, +})); +vi.mock("../../features/settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ themeVariables: {} }), +})); +vi.mock("../../features/settings/appearance/useScaledTextRole", () => ({ + useScaledTextRole: () => ({}), +})); +vi.mock("../../lib/mobileTheme", () => ({ themeColorWithContrast: () => "#ffffff" })); + +import * as Dialog from "./Dialog.android"; + +const roots: Root[] = []; + +function mountHost() { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + roots.push(root); + act(() => root.render()); + return { root, container }; +} + +afterEach(() => { + for (const root of roots.splice(0)) act(() => root.unmount()); + document.body.innerHTML = ""; +}); + +it("keeps presenting dialogs after an older root's Host unmounts", () => { + // Android activity replacement mounts the new root before the old one stops. + const previous = mountHost(); + const current = mountHost(); + roots.splice(roots.indexOf(previous.root), 1); + act(() => previous.root.unmount()); + + act(() => Dialog.alert("Saved", undefined, [{ text: "OK" }])); + + expect(current.container.textContent).toContain("Saved"); +}); diff --git a/apps/mobile/src/components/ui/Dialog.android.tsx b/apps/mobile/src/components/ui/Dialog.android.tsx index 5cd3519c1b60..626c6c305a5b 100644 --- a/apps/mobile/src/components/ui/Dialog.android.tsx +++ b/apps/mobile/src/components/ui/Dialog.android.tsx @@ -62,12 +62,15 @@ export function Host() { const [inputValue, setInputValue] = useState(""); const nextPresentationId = useRef(0); useEffect(() => { - presentRequest = (request) => { + const present = (request: DialogRequest) => { setInputValue(request.kind === "text-input" ? request.request.initialValue : ""); setPresented({ ...request, id: ++nextPresentationId.current }); }; + presentRequest = present; return () => { - presentRequest = null; + // When Android replaces the activity, the new root's Host registers before the old root + // unmounts. Clearing unconditionally would leave the live Host without dialogs. + if (presentRequest === present) presentRequest = null; }; }, []); diff --git a/apps/mobile/src/components/ui/NativeAppBar.android.tsx b/apps/mobile/src/components/ui/NativeAppBar.android.tsx index b06b0908356e..22f8ca65cc42 100644 --- a/apps/mobile/src/components/ui/NativeAppBar.android.tsx +++ b/apps/mobile/src/components/ui/NativeAppBar.android.tsx @@ -10,14 +10,19 @@ export function NativeAppBar(props: { readonly title: ReactElement; readonly navigationIcon?: ReactElement; readonly actions?: ReactElement; + /** Shorter bar for a one-row title. */ + readonly singleLine?: boolean; }) { const { colors } = useControlTheme(); - const layout = useToolbarLayout(); + const layout = useToolbarLayout(false, props.singleLine); return ( - + // The bar's height is fixed, so React Native lays out the screen below it from the first frame + // instead of at zero height until Compose has measured and reported the bar. + + ); +} + +export function SheetHeader(props: SheetHeaderProps) { + const confirmation = props.confirm; + const trailing = + props.trailing || confirmation ? ( + + {props.trailing} + {confirmation ? ( + -
hover me
- -
card
lane
-

- `); - await ServerBrowserPage.click(page, { locator: "#target", button: "right" }); - await ServerBrowserPage.click(page, { locator: "#target", clickCount: 2 }); - await ServerBrowserPage.hover(page, { locator: "#hover" }); - expect(await page.isVisible("#menu")).toBe(true); - expect(await ServerBrowserPage.select(page, { locator: "#size", values: ["Large"] })).toEqual({ - selected: ["l"], - }); - await ServerBrowserPage.drag(page, { source: "#card", target: "#lane" }); - expect(await page.textContent("#log")).toBe("context;dblclick;drop;"); - await expect( - ServerBrowserPage.select(page, { locator: "#target", values: ["s"] }), - ).rejects.toMatchObject({ tag: "PreviewAutomationTargetNotEditableError" }); - }); - - it("shows the agent's pointer at each target before the action reaches the page", async () => { - await page.setContent(` - -
tip
-
card
-
lane
- `); - const shown: Array = []; - const pointer: ServerBrowserPage.PointerReporter = async ({ x, y }, phase) => { - const seen = await page.evaluate("window.seen.length"); - shown.push(`${phase}@${Math.round(x)},${Math.round(y)} after ${seen}`); - }; - await ServerBrowserPage.click(page, { locator: "#go" }, pointer); - await ServerBrowserPage.hover(page, { locator: "#tip" }, pointer); - await ServerBrowserPage.drag(page, { source: "#card", target: "#lane" }, pointer); - expect(shown).toEqual([ - "click@140,50 after 0", - "move@330,50 after 1", - "move@40,140 after 2", - "move@270,140 after 2", - ]); - expect(await page.evaluate("window.seen")).toEqual(["click", "hover", "drop"]); - }); - - it("presents a headless page as Chrome, with client hints that agree", async () => { - await presentAsChrome(cdp, { platform: "linux", arch: "x64" }); - // userAgentData exists only in secure contexts; https comes from a route. - let secChUa: string | undefined; - await page.route("https://example.test/", (route) => { - secChUa = route.request().headers()["sec-ch-ua"]; - return route.fulfill({ contentType: "text/html", body: "

hi

" }); - }); - await page.goto("https://example.test/"); - const identity = (await page.evaluate(`(async () => ({ - userAgent: navigator.userAgent, - brands: navigator.userAgentData.brands.map((brand) => brand.brand), - full: (await navigator.userAgentData.getHighEntropyValues(["fullVersionList"])) - .fullVersionList.map((brand) => brand.brand), - }))()`)) as { userAgent: string; brands: string[]; full: string[] }; - expect(identity.userAgent).not.toContain("Headless"); - expect(identity.userAgent).toMatch(/ Chrome\/\d+\.0\.0\.0 /); - expect(identity.brands).toContain("Google Chrome"); - expect([...identity.brands, ...identity.full].join()).not.toContain("Headless"); - expect(secChUa).toContain('"Google Chrome"'); - expect(secChUa).not.toContain("Headless"); - }); - - it("stops an evaluation at its deadline so the page answers the next one", async () => { - await expect( - ServerBrowserPage.evaluate(cdp, { expression: "for (;;) {}" }, 200), - ).rejects.toMatchObject({ tag: "PreviewAutomationTimeoutError" }); - expect(await ServerBrowserPage.evaluate(cdp, { expression: "1 + 1" }, 2_000)).toBe(2); - }); }); describe("server browser drag", () => { @@ -357,8 +273,10 @@ describe("server browser drag", () => { ); void settled.then(() => (finished = true)); // Two macrotask turns: Node reports a rejection nobody observed after the first. - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); + for (let turn = 0; turn < 2; turn++) { + // oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- Node emits unhandledRejection only between macrotasks; no promise can mark that point. + await new Promise((resolve) => setImmediate(resolve)); + } expect(finished).toBe(false); targetBox.resolve(box); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 908de1b6c48b..02089cca3205 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -18,7 +18,7 @@ import { type PreviewAutomationWaitForInput, } from "@t3tools/contracts"; import { constVoid } from "effect/Function"; -import type { CDPSession, Frame, Locator, Page } from "playwright-core"; +import type { CDPSession, ConsoleMessage, Frame, Locator, Page } from "playwright-core"; import * as NodeCrypto from "node:crypto"; import { BrowserControlInterrupted } from "./SessionControl.ts"; @@ -149,6 +149,54 @@ const SNAPSHOT_SCRIPT = `(() => { })()`; const MASKED_VALUE = "••••••••"; + +/** + * A `data:` URL carries its content, which can be a whole file, and URLs and diagnostics + * reach tool results and provider logs. Keeps only the media type: the payload can hold any + * character and parameters are page-chosen too. + */ +export const withoutDataPayload = (url: string) => { + // Read the scheme as a browser would: it ignores leading controls and spaces, and tabs and + // newlines anywhere. A snapshot's YAML value may be quoted and escaped; an escape that isn't a + // tab or newline still decodes to something that can't start a scheme. + const value = url + .replace(/^["']/, "") + .replace(/\\(x[\da-f]{2}|u[\da-f]{4}|.)/gi, (_escape, code: string) => + code.length > 1 + ? String.fromCharCode(Number.parseInt(code.slice(1), 16)) + : ({ t: "\t", n: "\n", r: "\r", '"': '"', "\\": "\\" }[code] ?? "\u0001"), + ) + .replace(/[\t\n\r]/g, "") + // eslint-disable-next-line no-control-regex -- URL parsing ignores these controls. + .replace(/^[\u0000-\u0020]+/, ""); + const parsed = /^data:([^,;]*)/i.exec(value); + if (!parsed) return url; + const type = parsed[1]!.trim(); + const mediaType = /^[\w.+-]{1,64}\/[\w.+-]{1,64}$/.test(type) ? type : ""; + return `data:${mediaType},…`; +}; + +/** + * Chromium's own console messages, which carry no arguments, quote `data:` URLs whole, as in a + * blocked navigation or a CSP violation. Everything after the first one goes, since Chromium + * accepts any type and a payload can hold any character. What a page logs itself is its own + * text, like anything else it shows, so it stays. + */ +export const consoleText = (message: ConsoleMessage) => + message.args().length > 0 + ? message.text() + : message.text().replace(/(? + tree + .split("\n") + .map((line) => { + const url = /^(\s*- \/url: )(.*)$/s.exec(line); + return url ? url[1] + withoutDataPayload(url[2]!) : line; + }) + .join("\n"); + // The broker waits 15s for the host; a snapshot, retry included, answers well before. const SNAPSHOT_TIMEOUT_MS = 10_000; // How long a frame in another process gets to answer; one that crashed never does. @@ -373,19 +421,77 @@ const maskTree = (tree: string, dotted: RegExp) => }) .join("\n"); +/** Chromium sizes the capture, so the reported size is read from the PNG header. */ +const pngSize = (base64: string) => { + const header = Buffer.from(base64.slice(0, 32), "base64"); + return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) }; +}; + +type Viewport = { readonly width: number; readonly height: number }; + +/** + * A page's display ratio and CSS viewport, read in an isolated world since page script can + * reassign `devicePixelRatio`, `innerWidth`, and `innerHeight` in its own, and its page zoom. + */ +const readPageMetrics = async (cdp: CDPSession) => { + const { frameTree } = await cdp.send("Page.getFrameTree"); + const { executionContextId } = await cdp.send("Page.createIsolatedWorld", { + frameId: frameTree.frame.id, + worldName: "t3-preview-metrics", + }); + let { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics"); + // Desktop zoom applies asynchronously; bracket the CSS metrics to reject a torn read. + for (let attempt = 0; attempt < 3; attempt += 1) { + const zoom = cssVisualViewport.zoom ?? 1; + const { result } = await cdp.send("Runtime.evaluate", { + expression: "({ ratio: devicePixelRatio, width: innerWidth, height: innerHeight })", + contextId: executionContextId, + returnByValue: true, + }); + ({ cssVisualViewport } = await cdp.send("Page.getLayoutMetrics")); + if ((cssVisualViewport.zoom ?? 1) === zoom) { + const { ratio, width, height } = result.value as { + readonly ratio: number; + readonly width: number; + readonly height: number; + }; + return { + ratio, + width, + height, + zoom, + offset: { x: cssVisualViewport.pageX * zoom, y: cssVisualViewport.pageY * zoom }, + }; + } + } + throw new Error( + "The page's zoom kept changing while the snapshot read its size. Try the snapshot again.", + ); +}; + // Scaled captures repaint live screencasts, so callers pause them. Clips use document offsets. export const captureViewport = async ( page: Page, cdp: CDPSession, - options: { readonly format: "png" | "jpeg"; readonly quality?: number; readonly scale: number }, + options: { + readonly format: "png" | "jpeg"; + readonly quality?: number; + readonly scale: number; + readonly viewport?: Viewport; + readonly offset?: { readonly x: number; readonly y: number } | undefined; + }, ) => { let clip; if (options.scale < 1) { - const viewport = page.viewportSize() ?? { width: 1280, height: 800 }; - const { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics"); + const viewport = options.viewport ?? page.viewportSize() ?? { width: 1280, height: 800 }; + let offset = options.offset; + if (!offset) { + const { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics"); + const zoom = cssVisualViewport.zoom ?? 1; + offset = { x: cssVisualViewport.pageX * zoom, y: cssVisualViewport.pageY * zoom }; + } clip = { - x: cssVisualViewport.pageX, - y: cssVisualViewport.pageY, + ...offset, ...viewport, scale: options.scale, }; @@ -401,18 +507,39 @@ export const captureViewport = async ( type SnapshotInput = { readonly page: Page; readonly cdp: CDPSession; - readonly renderScale: number; + /** Headless pages render at the scale the server set; a desktop page at its display's. */ + readonly renderScale: number | "display"; readonly consoleEntries: ReadonlyArray; readonly networkEntries: ReadonlyArray; readonly actionTimeline: PreviewAutomationSnapshot["actionTimeline"]; }; +/** The scale a page renders at and its viewport in device-independent pixels, a clip's units. */ +const renderingOf = async (input: SnapshotInput) => { + if (input.renderScale !== "display") { + const viewport = input.page.viewportSize() ?? { width: 1280, height: 800 }; + return { renderScale: input.renderScale, viewport, offset: undefined }; + } + // The display ratio a desktop page reports includes its page zoom, and its sizes are CSS pixels. + const page = await readPageMetrics(input.cdp); + return { + renderScale: page.ratio / page.zoom, + offset: page.offset, + viewport: { + width: Math.round(page.width * page.zoom), + height: Math.round(page.height * page.zoom), + }, + }; +}; + const capture = async ( input: SnapshotInput, deadline: number, ): Promise => { - const viewport = input.page.viewportSize() ?? { width: 1280, height: 800 }; - const scale = Math.min(1, MAX_SCREENSHOT_WIDTH / (viewport.width * input.renderScale)); + const { renderScale, viewport, offset } = await renderingOf(input); + // A late metrics reply must not begin a capture after viewers have resumed. + if (Date.now() >= deadline) throw tooBusy(); + const scale = Math.min(1, MAX_SCREENSHOT_WIDTH / (viewport.width * renderScale)); const state = refsFor(input.page); invalidateRefs(input.page); const generation = state.generation; @@ -427,7 +554,7 @@ const capture = async ( "url" | "title" | "loading" | "visibleText" | "interactiveElements" > >, - captureViewport(input.page, input.cdp, { format: "png", scale }), + captureViewport(input.page, input.cdp, { format: "png", scale, viewport, offset }), ]); void pageAndScreenshot.catch(constVoid); // Leaves time for the second read; a frame that never loads holds the tree until then. @@ -455,7 +582,10 @@ const capture = async ( const [page, data] = await pageAndScreenshot; if (state.generation !== generation) throw pageChanged(); const mask = (text: string) => (dotted ? text.replace(dotted, MASKED_VALUE) : text); - const accessibilityTree = (dotted ? maskTree(tree, dotted) : tree) + // Redacted before the cut, so a payload crossing it can't leave its start showing, and before + // masking, which could hide the `data:` scheme behind a password value's dots. + const redacted = withoutTreeDataPayloads(tree); + const accessibilityTree = (dotted ? maskTree(redacted, dotted) : redacted) .slice(0, MAX_VISIBLE_TEXT_LENGTH) .replace(/\[ref=((?:f\d+)?e\d+)\]/g, (_match, nativeRef: string) => { const ref = `t3-${generation}-${nativeRef}`; @@ -474,8 +604,7 @@ const capture = async ( screenshot: { mimeType: "image/png", data, - width: Math.round(viewport.width * input.renderScale * scale), - height: Math.round(viewport.height * input.renderScale * scale), + ...pngSize(data), }, }; }; diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts index 536d3f2c1920..ad42eaf5d729 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -51,164 +51,164 @@ const parseMessage = (chunk: Uint8Array | string): unknown => { } }; -const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const url = HttpServerRequest.toURL(request); - if (Option.isNone(url)) return HttpServerResponse.text("Bad Request", { status: 400 }); - if (url.value.pathname === `${PREVIEW_STREAM_ROUTE_PREFIX}/download`) { - return yield* serveDownload(browser, url.value.searchParams); - } - if (url.value.pathname === `${PREVIEW_STREAM_ROUTE_PREFIX}/upload`) { - return request.method === "POST" - ? yield* receiveUpload(browser, url.value.searchParams) - : HttpServerResponse.text("Method Not Allowed", { status: 405 }); - } - if ( - url.value.pathname !== `${PREVIEW_STREAM_ROUTE_PREFIX}/ws` || - request.headers.upgrade?.toLowerCase() !== "websocket" - ) { - return HttpServerResponse.text("Not Found", { status: 404 }); - } - const session = yield* authenticateMediaRequest(AuthOrchestrationReadScope); - const params = url.value.searchParams; - const canOperate = - session.scopes.includes(AuthPreviewOperateScope) && params.get("interactive") !== "false"; - const threadId = params.get("threadId") ?? ""; - const tabId = params.get("tabId") ?? ""; - if (threadId.length === 0 || tabId.length === 0) { - return HttpServerResponse.text("Not Found", { status: 404 }); - } - return yield* Effect.scoped( - Effect.gen(function* () { - const attached = yield* browser - .attachViewer({ - threadId, - tabId, - canOperate, - maxWidth: intParam(params, "maxWidth", 1280, 7680), - maxHeight: intParam(params, "maxHeight", 800, 4320), - quality: intParam(params, "quality", DEFAULT_QUALITY, 100), - }) - .pipe( - Effect.map((viewer) => ({ _tag: "attached" as const, viewer })), - Effect.catchTags({ - ServerBrowserTabNotFoundError: () => Effect.succeed({ _tag: "gone" as const }), - ServerBrowserLaunchError: (error) => { - const setup = hostSetup(error.cause); - return setup === undefined - ? Effect.fail(error) - : Effect.succeed({ _tag: "hostSetup" as const, reason: encodeHostSetup(setup) }); - }, - }), - ); - const incoming = NodeHttpServerRequest.toIncomingMessage(request); - // JPEGs are already compressed. Disabling deflate also keeps all - // pending writes in the socket buffer we bound below, not a zlib queue. - delete incoming.headers["sec-websocket-extensions"]; - const transport = incoming.socket; - const socket = yield* request.upgrade; - const reader = yield* socket.reader; - const writer = yield* socket.writer; - // A refused upgrade reads as an auth failure to ticket clients, so a - // missing tab is a close code they stop on. - const gone = writer.write(new Socket.CloseEvent(TAB_GONE_CODE, "tab closed")); - if (attached._tag === "gone") { - yield* gone; - return HttpServerResponse.empty(); - } - if (attached._tag === "hostSetup") { - yield* writer.write( - new Socket.CloseEvent(PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, attached.reason), - ); - return HttpServerResponse.empty(); - } - const viewer = attached.viewer; - // `write` returns once the frame is queued, so Chromium's ack for each - // frame waits for the viewer's `ack` message instead. Only one frame - // is on the wire and only the newest waits behind it, so replies never - // queue behind stale images on a slow link. - let inFlight: Effect.Effect | null = null; - let pending: Frame | null = null; - const disconnectSlowViewer = Effect.sync(() => transport.destroy()).pipe( - Effect.andThen(Effect.interrupt), +// Request middleware owns tracing, including long-lived WebSocket sessions. +const makeHandler = Effect.fnUntraced(function* (browser: ServerBrowser.ServerBrowser["Service"]) { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) return HttpServerResponse.text("Bad Request", { status: 400 }); + if (url.value.pathname === `${PREVIEW_STREAM_ROUTE_PREFIX}/download`) { + return yield* serveDownload(browser, url.value.searchParams); + } + if (url.value.pathname === `${PREVIEW_STREAM_ROUTE_PREFIX}/upload`) { + return request.method === "POST" + ? yield* receiveUpload(browser, url.value.searchParams) + : HttpServerResponse.text("Method Not Allowed", { status: 405 }); + } + if ( + url.value.pathname !== `${PREVIEW_STREAM_ROUTE_PREFIX}/ws` || + request.headers.upgrade?.toLowerCase() !== "websocket" + ) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const session = yield* authenticateMediaRequest(AuthOrchestrationReadScope); + const params = url.value.searchParams; + const canOperate = + session.scopes.includes(AuthPreviewOperateScope) && params.get("interactive") !== "false"; + const threadId = params.get("threadId") ?? ""; + const tabId = params.get("tabId") ?? ""; + if (threadId.length === 0 || tabId.length === 0) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + return yield* Effect.scoped( + Effect.gen(function* () { + const attached = yield* browser + .attachViewer({ + threadId, + tabId, + canOperate, + maxWidth: intParam(params, "maxWidth", 1280, 7680), + maxHeight: intParam(params, "maxHeight", 800, 4320), + quality: intParam(params, "quality", DEFAULT_QUALITY, 100), + }) + .pipe( + Effect.map((viewer) => ({ _tag: "attached" as const, viewer })), + Effect.catchTags({ + ServerBrowserTabNotFoundError: () => Effect.succeed({ _tag: "gone" as const }), + ServerBrowserLaunchError: (error) => { + const setup = hostSetup(error.cause); + return setup === undefined + ? Effect.fail(error) + : Effect.succeed({ _tag: "hostSetup" as const, reason: encodeHostSetup(setup) }); + }, + }), + ); + const incoming = NodeHttpServerRequest.toIncomingMessage(request); + // JPEGs are already compressed. Disabling deflate also keeps all + // pending writes in the socket buffer we bound below, not a zlib queue. + delete incoming.headers["sec-websocket-extensions"]; + const transport = incoming.socket; + const socket = yield* request.upgrade; + const reader = yield* socket.reader; + const writer = yield* socket.writer; + // A refused upgrade reads as an auth failure to ticket clients, so a + // missing tab is a close code they stop on. + const gone = writer.write(new Socket.CloseEvent(TAB_GONE_CODE, "tab closed")); + if (attached._tag === "gone") { + yield* gone; + return HttpServerResponse.empty(); + } + if (attached._tag === "hostSetup") { + yield* writer.write( + new Socket.CloseEvent(PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, attached.reason), ); - const write = (data: Uint8Array | string) => - Effect.suspend(() => { - const bytes = typeof data === "string" ? Buffer.byteLength(data) : data.byteLength; - if (transport.writableLength + bytes + 14 > MAX_SOCKET_BUFFER_BYTES) { - return disconnectSlowViewer; + return HttpServerResponse.empty(); + } + const viewer = attached.viewer; + // `write` returns once the frame is queued, so Chromium's ack for each + // frame waits for the viewer's `ack` message instead. Only one frame + // is on the wire and only the newest waits behind it, so replies never + // queue behind stale images on a slow link. + let inFlight: Effect.Effect | null = null; + let pending: Frame | null = null; + const disconnectSlowViewer = Effect.sync(() => transport.destroy()).pipe( + Effect.andThen(Effect.interrupt), + ); + const write = (data: Uint8Array | string) => + Effect.suspend(() => { + const bytes = typeof data === "string" ? Buffer.byteLength(data) : data.byteLength; + if (transport.writableLength + bytes + 14 > MAX_SOCKET_BUFFER_BYTES) { + return disconnectSlowViewer; + } + return writer.write(data); + }); + const sendFrame = (frame: Frame) => { + inFlight = frame.ack; + return write(frame.data); + }; + const sendOutput = Queue.take(viewer.output).pipe( + Effect.flatMap((output) => { + switch (output._tag) { + case "frame": { + if (inFlight === null) return sendFrame(output); + const superseded = pending; + pending = output; + // Never sent, but Chromium still waits for its ack. + return superseded ? Effect.forkScoped(superseded.ack) : Effect.void; } - return writer.write(data); - }); - const sendFrame = (frame: Frame) => { - inFlight = frame.ack; - return write(frame.data); - }; - const sendOutput = Queue.take(viewer.output).pipe( - Effect.flatMap((output) => { - switch (output._tag) { - case "frame": { - if (inFlight === null) return sendFrame(output); - const superseded = pending; - pending = output; - // Never sent, but Chromium still waits for its ack. - return superseded ? Effect.forkScoped(superseded.ack) : Effect.void; - } - case "viewport": - case "control": - case "clipboard": - case "download": - case "fileChooser": - case "fileChooserClosed": - case "popup": - case "pointer": - case "probe": { - const { _tag: type, ...data } = output; - return write(JSON.stringify({ type, ...data })); - } - case "gone": - return gone.pipe(Effect.andThen(Effect.interrupt)); - case "reconnect": - return writer - .write(new Socket.CloseEvent(1012, "reconnect")) - .pipe(Effect.andThen(Effect.interrupt)); + case "viewport": + case "control": + case "clipboard": + case "download": + case "fileChooser": + case "fileChooserClosed": + case "popup": + case "pointer": + case "probe": { + const { _tag: type, ...data } = output; + return write(JSON.stringify({ type, ...data })); } - }), - ); - const receive = (chunk: Uint8Array | string) => { - const message = parseMessage(chunk); - // Ownership serializes actions in the service. Keep reading so dialog - // replies and takeover can unblock an action already waiting on the page. - if (!isAck(message)) - return canOperate - ? viewer.input(message).pipe(Effect.forkScoped, Effect.asVoid) - : Effect.void; - const ack = inFlight; - const next = pending; - inFlight = null; - pending = null; - // Forked: Chromium acks are paced and must not hold up input. - return Effect.andThen( - ack ? Effect.forkScoped(ack) : Effect.void, - next ? sendFrame(next) : Effect.void, - ); - }; - const receiveInput = reader.pull.pipe( - Effect.flatMap((chunks) => Effect.forEach(chunks, receive, { discard: true })), + case "gone": + return gone.pipe(Effect.andThen(Effect.interrupt)); + case "reconnect": + return writer + .write(new Socket.CloseEvent(1012, "reconnect")) + .pipe(Effect.andThen(Effect.interrupt)); + } + }), + ); + const receive = (chunk: Uint8Array | string) => { + const message = parseMessage(chunk); + // Ownership serializes actions in the service. Keep reading so dialog + // replies and takeover can unblock an action already waiting on the page. + if (!isAck(message)) + return canOperate + ? viewer.input(message).pipe(Effect.forkScoped, Effect.asVoid) + : Effect.void; + const ack = inFlight; + const next = pending; + inFlight = null; + pending = null; + // Forked: Chromium acks are paced and must not hold up input. + return Effect.andThen( + ack ? Effect.forkScoped(ack) : Effect.void, + next ? sendFrame(next) : Effect.void, ); - return yield* Effect.raceFirst(Effect.forever(sendOutput), Effect.forever(receiveInput)); - }), - ).pipe( - Effect.catchTags({ - ServerBrowserLaunchError: (error) => - Effect.logWarning("server preview browser failed to start", { cause: error.cause }).pipe( - Effect.as(HttpServerResponse.text("Service Unavailable", { status: 503 })), - ), - }), - Effect.catch(() => Effect.succeed(HttpServerResponse.empty())), - ); - }); + }; + const receiveInput = reader.pull.pipe( + Effect.flatMap((chunks) => Effect.forEach(chunks, receive, { discard: true })), + ); + return yield* Effect.raceFirst(Effect.forever(sendOutput), Effect.forever(receiveInput)); + }), + ).pipe( + Effect.catchTags({ + ServerBrowserLaunchError: (error) => + Effect.logWarning("server preview browser failed to start", { cause: error.cause }).pipe( + Effect.as(HttpServerResponse.text("Service Unavailable", { status: 503 })), + ), + }), + Effect.catch(() => Effect.succeed(HttpServerResponse.empty())), + ); +}); const hostSetup = (cause: unknown): PreviewStreamHostSetup | undefined => isSandboxError(cause) @@ -220,28 +220,32 @@ const isSandboxError = Schema.is(PreviewBrowserHost.PreviewBrowserSandboxError); const isLibrariesError = Schema.is(PreviewBrowserHost.PreviewBrowserLibrariesError); const encodeHostSetup = Schema.encodeSync(Schema.fromJsonString(PreviewStreamHostSetup)); -const serveDownload = (browser: ServerBrowser.ServerBrowser["Service"], params: URLSearchParams) => - Effect.gen(function* () { - yield* authenticateMediaRequest(AuthOrchestrationReadScope); - const download = yield* browser.openDownload({ - threadId: params.get("threadId") ?? "", - tabId: params.get("tabId") ?? "", - downloadId: params.get("id") ?? "", - }); - if (Option.isNone(download)) return HttpServerResponse.text("Not Found", { status: 404 }); - return yield* HttpServerResponse.file(download.value.path, { - headers: assetResponseHeaders(download.value.path, { - download: true, - fileName: download.value.fileName, - }), - }); +// The request span already covers this download route. +const serveDownload = Effect.fnUntraced(function* ( + browser: ServerBrowser.ServerBrowser["Service"], + params: URLSearchParams, +) { + yield* authenticateMediaRequest(AuthOrchestrationReadScope); + const download = yield* browser.openDownload({ + threadId: params.get("threadId") ?? "", + tabId: params.get("tabId") ?? "", + downloadId: params.get("id") ?? "", + }); + if (Option.isNone(download)) return HttpServerResponse.text("Not Found", { status: 404 }); + return yield* HttpServerResponse.file(download.value.path, { + headers: assetResponseHeaders(download.value.path, { + download: true, + fileName: download.value.fileName, + }), }); +}); const UPLOAD_MAX_FILE_BYTES = 100 * 1024 * 1024; const UPLOAD_MAX_FILES = 20; -const receiveUpload = (browser: ServerBrowser.ServerBrowser["Service"], params: URLSearchParams) => - Effect.gen(function* () { +// The request span already covers this upload route. +const receiveUpload = Effect.fnUntraced( + function* (browser: ServerBrowser.ServerBrowser["Service"], params: URLSearchParams) { yield* authenticateMediaRequest(AuthPreviewOperateScope); const request = yield* HttpServerRequest.HttpServerRequest; const fs = yield* FileSystem.FileSystem; @@ -276,7 +280,9 @@ const receiveUpload = (browser: ServerBrowser.ServerBrowser["Service"], params: return answered ? HttpServerResponse.empty({ status: 204 }) : HttpServerResponse.text("The page's file picker is no longer open.", { status: 409 }); - }).pipe(Effect.scoped); + }, + (effect, _browser, _params) => effect.pipe(Effect.scoped), +); export const routeLayer = HttpRouter.use((router) => Effect.gen(function* () { diff --git a/apps/server/src/preview/SessionControl.test.ts b/apps/server/src/preview/SessionControl.test.ts index 42eabc212ac0..a5971f010a57 100644 --- a/apps/server/src/preview/SessionControl.test.ts +++ b/apps/server/src/preview/SessionControl.test.ts @@ -157,16 +157,6 @@ describe("SessionControl", () => { expect(control.generation).toBe(1); }); - it("does not let a failed action poison the serial queue", async () => { - const control = new SessionControl("agent"); - await expect( - control.agent("agent", async () => { - throw new Error("navigation failed"); - }), - ).rejects.toThrow("navigation failed"); - await expect(control.agent("agent", async () => "recovered")).resolves.toBe("recovered"); - }); - it("lets any agent act on a tab no agent opened, but only while no human controls it", async () => { const control = new SessionControl(null); await expect(control.agent("agent-a", async () => "acted")).resolves.toBe("acted"); diff --git a/apps/server/src/preview/SessionControl.ts b/apps/server/src/preview/SessionControl.ts index 690d42166ab9..cee60e2f9532 100644 --- a/apps/server/src/preview/SessionControl.ts +++ b/apps/server/src/preview/SessionControl.ts @@ -73,13 +73,19 @@ export class SessionControl { this.onGenerationChange(); } - private async action(allowed: () => boolean, run: () => Promise) { + private async action(allowed: () => boolean, run: () => Promise, deadlineMs?: number) { this.assertOpen(); if (!allowed()) throw new BrowserControlInterrupted("You do not control this browser tab."); const epoch = this.epoch; return this.enqueue(async () => { this.assertOpen(); if (epoch !== this.epoch || !allowed()) throw new BrowserControlInterrupted(); + if (deadlineMs !== undefined && performance.now() >= deadlineMs) { + throw new DOMException( + "This browser request expired before it could start.", + "TimeoutError", + ); + } return run(); }); } @@ -88,7 +94,7 @@ export class SessionControl { return this.agentId === null || this.agentId === agentId; } - agent(agentId: string, run: () => Promise) { + agent(agentId: string, run: () => Promise, deadlineMs?: number) { if (!this.agentMayAct(agentId)) return Promise.reject( new BrowserControlInterrupted("This tab belongs to another agent.", "agentMismatch"), @@ -97,7 +103,7 @@ export class SessionControl { return Promise.reject( new BrowserControlInterrupted("A human controls this tab.", "humanControl"), ); - return this.action(() => this.agentMayAct(agentId) && this.owner === null, run); + return this.action(() => this.agentMayAct(agentId) && this.owner === null, run, deadlineMs); } agentCanActNow(agentId: string) { diff --git a/apps/server/src/preview/testing/DesktopPipeLifecycle.fixture.ts b/apps/server/src/preview/testing/DesktopPipeLifecycle.fixture.ts deleted file mode 100644 index b9fee70bf39f..000000000000 --- a/apps/server/src/preview/testing/DesktopPipeLifecycle.fixture.ts +++ /dev/null @@ -1,57 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - Entry point for the inherited-pipe shutdown regression test. -import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Console from "effect/Console"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Stream from "effect/Stream"; - -import * as ServerSettings from "../../serverSettings.ts"; - -import * as ServerConfig from "../../config.ts"; -import * as DesktopTelemetryReceiver from "../../resourceTelemetry/DesktopTelemetryReceiver.ts"; -import * as DesktopBrowserChannel from "../DesktopBrowserChannel.ts"; - -const key = { threadId: "thread-1", tabId: "tab-1" }; - -Effect.gen(function* () { - const base = yield* ServerConfig.ServerConfig.pipe( - Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-pipe-lifecycle-" })), - ); - const context = yield* Layer.build( - Layer.merge(DesktopBrowserChannel.layer, DesktopTelemetryReceiver.layer).pipe( - Layer.provide(ServerSettings.layerTest()), - Layer.provide( - Layer.succeed(ServerConfig.ServerConfig, { - ...base, - desktopBrowserFd: 3, - desktopBrowserControlFd: 4, - desktopTelemetryFd: 5, - desktopTelemetryControlFd: 6, - }), - ), - ), - ); - const browser = Context.get(context, DesktopBrowserChannel.DesktopBrowserChannel); - const telemetry = Context.get(context, DesktopTelemetryReceiver.DesktopTelemetryReceiver); - const health = yield* telemetry.subscribeHealth; - const healthy = yield* health.changes.pipe( - Stream.filter((value) => value.status === "healthy"), - Stream.runHead, - Effect.forkScoped, - ); - const detached = yield* browser.detached.pipe(Stream.runHead, Effect.forkScoped); - yield* Console.log("ready"); - if (!(yield* browser.awaitAttached(key, "5 seconds"))) { - return yield* Effect.die("The browser attach message was not received."); - } - yield* Fiber.join(healthy); - yield* Console.log("attached"); - yield* Fiber.join(detached); - const endpoint = yield* Effect.exit(Effect.scoped(browser.endpoint(key))); - if (Exit.isSuccess(endpoint)) return yield* Effect.die("A detached tab received an endpoint."); - yield* Console.log("verified"); -}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain); diff --git a/apps/server/src/process/agentScope.test.ts b/apps/server/src/process/agentScope.test.ts index 396fc22987d4..30be9346dc1d 100644 --- a/apps/server/src/process/agentScope.test.ts +++ b/apps/server/src/process/agentScope.test.ts @@ -4,23 +4,17 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, expect, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; +import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { ChildProcessSpawner } from "effect/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as ServerConfig from "../config.ts"; +import { makeCodexAppServerSpawnCommand } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as ProcessRunner from "../processRunner.ts"; -import { - agentSliceMemoryLimits, - classifyScope, - make, - ownedAgentSlice, - type ScopeState, -} from "./agentScope.ts"; - -const GiB = 1024 ** 3; +import { classifyScope, make, ownedAgentSlice, type ScopeState } from "./agentScope.ts"; const running: ScopeState = { loadState: "loaded", @@ -39,26 +33,6 @@ describe("classifyScope", () => { expect(classifyScope({ ...running, populated: false })).toBe("stopping"); expect(classifyScope({ ...running, activeState: "deactivating" })).toBe("stopping"); }); - - it("does not wait on a live agent or a scope that ended for another reason", () => { - expect(classifyScope(running)).toBe("running"); - expect(classifyScope({ ...running, activeState: "inactive", populated: undefined })).toBe( - "gone", - ); - expect(classifyScope({ ...running, loadState: "not-found", activeState: "inactive" })).toBe( - "gone", - ); - }); -}); - -describe("agentSliceMemoryLimits", () => { - it("leaves 6 GB before throttling and 4 GB before a kill on large machines", () => { - expect(agentSliceMemoryLimits(64 * GiB)).toEqual({ high: 58 * GiB, max: 60 * GiB }); - }); - - it("still gives agents most of a small machine", () => { - expect(agentSliceMemoryLimits(8 * GiB)).toEqual({ high: 4 * GiB, max: 6 * GiB }); - }); }); const withFakeSystemd = ( @@ -68,6 +42,7 @@ const withFakeSystemd = ( readonly calls: ReadonlyArray>; readonly stateDir: string; }) => Effect.Effect, + { oomPolicy = true }: { readonly oomPolicy?: boolean } = {}, ) => Effect.gen(function* () { const bin = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agent-scope-")); @@ -76,10 +51,10 @@ const withFakeSystemd = ( } const units = new Map(); const calls: Array> = []; - const output = (stdout: string): ProcessRunner.ProcessRunOutput => ({ + const output = (stdout: string, code = 0): ProcessRunner.ProcessRunOutput => ({ stdout, stderr: "", - code: ChildProcessSpawner.ExitCode(0), + code: ChildProcessSpawner.ExitCode(code), timedOut: false, stdoutTruncated: false, stderrTruncated: false, @@ -90,6 +65,9 @@ const withFakeSystemd = ( run: ({ args }) => Effect.sync(() => { calls.push(args); + if (!oomPolicy && args.some((arg) => arg.startsWith("--property=OOMPolicy="))) { + return output("", 1); + } const [, verb, unit = ""] = args; if (verb === "reset-failed") units.delete(unit); if (verb !== "show") return output(""); @@ -125,6 +103,16 @@ const unitOf = (launch: { readonly args: ReadonlyArray }) => const sliceOf = (launch: { readonly args: ReadonlyArray }) => launch.args.find((arg) => arg.startsWith("--slice="))?.slice("--slice=".length) ?? ""; +const launchCodex = (scope: Effect.Success, threadId: string) => + makeCodexAppServerSpawnCommand({ command: "agent", args: ["app-server"], threadId }).pipe( + Effect.provideService(AgentScope, scope), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.map((command) => { + assert(ChildProcess.isStandardCommand(command)); + return command; + }), + ); + describe("AgentScope service", () => { it.effect("labels a thread from its newest scope until its next session clears it", () => withFakeSystemd(({ scope, units }) => @@ -146,6 +134,32 @@ describe("AgentScope service", () => { ), ); + it.effect( + "keeps shared Codex scopes running without attributing their OOM to the launch thread", + () => + withFakeSystemd(({ scope, units, calls }) => + Effect.gen(function* () { + const agent = yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" }); + expect(agent.args).toContain("--property=OOMPolicy=stop"); + units.set(unitOf(agent), { activeState: "failed", result: "oom-kill" }); + expect(yield* scope.oomKilled("a")).toBe(true); + + const codex = yield* launchCodex(scope, "a"); + expect(codex.args).toContain("--property=OOMPolicy=continue"); + expect(codex.args).not.toContain("--property=OOMPolicy=stop"); + expect(codex.args).toContain("--property=TimeoutStopSec=10s"); + expect(codex.args).toContain("--collect"); + units.set(unitOf(codex), { activeState: "active", result: "success" }); + expect(yield* scope.oomKilled("a")).toBe(false); + units.set(unitOf(codex), { activeState: "failed", result: "oom-kill" }); + expect(yield* scope.oomKilled("a")).toBe(false); + expect( + calls.filter(([, verb, unit]) => verb === "show" && unit === unitOf(codex)), + ).toEqual([]); + }), + ), + ); + it.effect("keeps a running scope while hundreds of other threads launch", () => withFakeSystemd(({ scope, units }) => Effect.gen(function* () { @@ -180,13 +194,4 @@ describe("AgentScope service", () => { }), ), ); - - it.effect("leaves a quiet slice alone", () => - withFakeSystemd(({ scope, calls }) => - Effect.gen(function* () { - yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" }); - expect(calls.filter(([, verb]) => verb === "stop")).toEqual([]); - }), - ), - ); }); diff --git a/apps/server/src/process/agentScope.ts b/apps/server/src/process/agentScope.ts index ed69eb2ad753..74858468d1b8 100644 --- a/apps/server/src/process/agentScope.ts +++ b/apps/server/src/process/agentScope.ts @@ -33,7 +33,7 @@ const GiB = 1024 ** 3; * working. Small machines still give agents at least half of RAM before * throttling and three quarters before a kill. */ -export function agentSliceMemoryLimits(totalBytes: number) { +function agentSliceMemoryLimits(totalBytes: number) { return { high: Math.max(totalBytes - 6 * GiB, Math.floor(totalBytes / 2)), max: Math.max(totalBytes - 4 * GiB, Math.floor(totalBytes * 0.75)), @@ -121,31 +121,32 @@ export const make = Effect.gen(function* () { // Old agents keep their scopes when the server is killed, and the restarted // server resumes their sessions. Stop them first, so two copies of one // session never work in the same worktree. - const stopLeftoverAgents = (systemctl: string) => - Effect.gen(function* () { - const shown = yield* run(systemctl, [ - "--user", - "show", - ownedSlice, - "--property=ActiveState,ControlGroup", - ]); - if (shown._tag === "None") return; - const values = parseKeyValues(shown.value.stdout); - if (values.get("ActiveState") !== "active") return; - const controlGroup = values.get("ControlGroup") ?? ""; - const events = controlGroup ? yield* readCgroupFile(controlGroup, "cgroup.events") : ""; - if (parseCgroupCounter(events, "populated") === 0) return; - yield* Effect.logWarning("Stopping agents left by a previous server", { slice: ownedSlice }); - const stopped = yield* runner - .run({ command: systemctl, args: ["--user", "stop", ownedSlice], timeout: "30 seconds" }) - .pipe(Effect.option); - if (stopped._tag === "None" || stopped.value.code !== 0) { - yield* Effect.logWarning("Could not stop agents left by a previous server", { - slice: ownedSlice, - stderr: stopped._tag === "Some" ? stopped.value.stderr.trim() : undefined, - }); - } - }); + const stopLeftoverAgents = Effect.fn("agentScope.stopLeftoverAgents")(function* ( + systemctl: string, + ) { + const shown = yield* run(systemctl, [ + "--user", + "show", + ownedSlice, + "--property=ActiveState,ControlGroup", + ]); + if (shown._tag === "None") return; + const values = parseKeyValues(shown.value.stdout); + if (values.get("ActiveState") !== "active") return; + const controlGroup = values.get("ControlGroup") ?? ""; + const events = controlGroup ? yield* readCgroupFile(controlGroup, "cgroup.events") : ""; + if (parseCgroupCounter(events, "populated") === 0) return; + yield* Effect.logWarning("Stopping agents left by a previous server", { slice: ownedSlice }); + const stopped = yield* runner + .run({ command: systemctl, args: ["--user", "stop", ownedSlice], timeout: "30 seconds" }) + .pipe(Effect.option); + if (stopped._tag === "None" || stopped.value.code !== 0) { + yield* Effect.logWarning("Could not stop agents left by a previous server", { + slice: ownedSlice, + stderr: stopped._tag === "Some" ? stopped.value.stderr.trim() : undefined, + }); + } + }); const systemd = yield* Effect.cached( Effect.gen(function* () { @@ -166,17 +167,9 @@ export const make = Effect.gen(function* () { "--", "true", ]).pipe(Effect.map((result) => result._tag === "Some" && result.value.code === 0)); - // Stop the whole scope when the kernel kills one of its processes, and - // record the result as oom-kill. Scopes take OOMPolicy from systemd 253; - // older user managers still get scopes, without the OOM label. - const stopOptions = [STOP_TIMEOUT_OPTION]; - const oomOptions = [...stopOptions, "--property=OOMPolicy=stop"]; - const scopeOptions = (yield* probe(oomOptions)) - ? oomOptions - : (yield* probe(stopOptions)) - ? stopOptions - : undefined; - if (scopeOptions === undefined) { + // Scopes take OOMPolicy from systemd 253; older managers still get scopes. + const supportsOomPolicy = yield* probe([STOP_TIMEOUT_OPTION, "--property=OOMPolicy=stop"]); + if (!supportsOomPolicy && !(yield* probe([STOP_TIMEOUT_OPTION]))) { yield* Effect.logInfo("Agent scopes are off: no systemd user manager"); return undefined; } @@ -197,86 +190,89 @@ export const make = Effect.gen(function* () { yield* Effect.logInfo("Agent scopes are on", { slice: AGENT_SLICE, ...limits, - oomPolicy: scopeOptions.length > 0, + oomPolicy: supportsOomPolicy, }); return { systemdRun, systemctl, runtimeDir, - scopeOptions, + supportsOomPolicy, }; }), ); const scopeByThread = new Map(); - const readScope = (systemctl: string, unit: string) => - Effect.gen(function* () { - const shown = yield* run(systemctl, [ - "--user", - "show", - unit, - "--property=LoadState,ActiveState,Result,ControlGroup", - ]); - if (shown._tag === "None" || shown.value.code !== 0) return undefined; - const values = parseKeyValues(shown.value.stdout); - const controlGroup = values.get("ControlGroup") ?? ""; - const memoryEvents = controlGroup ? yield* readCgroupFile(controlGroup, "memory.events") : ""; - const cgroupEvents = controlGroup ? yield* readCgroupFile(controlGroup, "cgroup.events") : ""; - const populated = parseCgroupCounter(cgroupEvents, "populated"); - return { - loadState: values.get("LoadState") ?? "", - activeState: values.get("ActiveState") ?? "", - result: values.get("Result") ?? "", - oomKills: parseCgroupCounter(memoryEvents, "oom_kill") ?? 0, - populated: populated === undefined ? undefined : populated === 1, - } satisfies ScopeState; - }); + const readScope = Effect.fn("agentScope.readScope")(function* (systemctl: string, unit: string) { + const shown = yield* run(systemctl, [ + "--user", + "show", + unit, + "--property=LoadState,ActiveState,Result,ControlGroup", + ]); + if (shown._tag === "None" || shown.value.code !== 0) return undefined; + const values = parseKeyValues(shown.value.stdout); + const controlGroup = values.get("ControlGroup") ?? ""; + const memoryEvents = controlGroup ? yield* readCgroupFile(controlGroup, "memory.events") : ""; + const cgroupEvents = controlGroup ? yield* readCgroupFile(controlGroup, "cgroup.events") : ""; + const populated = parseCgroupCounter(cgroupEvents, "populated"); + return { + loadState: values.get("LoadState") ?? "", + activeState: values.get("ActiveState") ?? "", + result: values.get("Result") ?? "", + oomKills: parseCgroupCounter(memoryEvents, "oom_kill") ?? 0, + populated: populated === undefined ? undefined : populated === 1, + } satisfies ScopeState; + }); - const clearFailed = (systemctl: string, unit: string) => - Effect.gen(function* () { - for (let attempt = 0; attempt < CLEAR_FAILED_ATTEMPTS; attempt++) { - const state = yield* readScope(systemctl, unit); - if (state === undefined) return; - if (state.activeState === "failed") { - yield* run(systemctl, ["--user", "reset-failed", unit]); - return; - } - if (state.activeState !== "deactivating") return; - yield* Effect.sleep(CLEAR_FAILED_INTERVAL); + const clearFailed = Effect.fn("agentScope.clearFailed")(function* ( + systemctl: string, + unit: string, + ) { + for (let attempt = 0; attempt < CLEAR_FAILED_ATTEMPTS; attempt++) { + const state = yield* readScope(systemctl, unit); + if (state === undefined) return; + if (state.activeState === "failed") { + yield* run(systemctl, ["--user", "reset-failed", unit]); + return; } - }); + if (state.activeState !== "deactivating") return; + yield* Effect.sleep(CLEAR_FAILED_INTERVAL); + } + }); const forget = (systemctl: string, unit: string) => clearFailed(systemctl, unit).pipe(Effect.forkIn(layerScope), Effect.asVoid); - const evictOldest = (systemctl: string) => - Effect.gen(function* () { - const oldest = scopeByThread.entries().next(); - if (oldest.done === true) return; - const [threadId, tracked] = oldest.value; - const state = yield* readScope(systemctl, tracked.unit); - if (scopeByThread.get(threadId) !== tracked) return; - scopeByThread.delete(threadId); - const status = state === undefined ? "running" : classifyScope(state); - if (status === "running" || status === "stopping") { - scopeByThread.set(threadId, tracked); - return; - } - yield* forget(systemctl, tracked.unit); - }); + const evictOldest = Effect.fn("agentScope.evictOldest")(function* (systemctl: string) { + const oldest = scopeByThread.entries().next(); + if (oldest.done === true) return; + const [threadId, tracked] = oldest.value; + const state = yield* readScope(systemctl, tracked.unit); + if (scopeByThread.get(threadId) !== tracked) return; + scopeByThread.delete(threadId); + const status = state === undefined ? "running" : classifyScope(state); + if (status === "running" || status === "stopping") { + scopeByThread.set(threadId, tracked); + return; + } + yield* forget(systemctl, tracked.unit); + }); - const track = (systemctl: string, threadId: string, unit: string | undefined) => - Effect.gen(function* () { - const previous = scopeByThread.get(threadId); - scopeByThread.delete(threadId); - if (unit !== undefined) scopeByThread.set(threadId, { unit, oomKilled: false }); - if (previous !== undefined) yield* forget(systemctl, previous.unit); - if (scopeByThread.size > MAX_TRACKED_THREADS) yield* evictOldest(systemctl); - }); + const track = Effect.fn("agentScope.track")(function* ( + systemctl: string, + threadId: string, + unit: string | undefined, + ) { + const previous = scopeByThread.get(threadId); + scopeByThread.delete(threadId); + if (unit !== undefined) scopeByThread.set(threadId, { unit, oomKilled: false }); + if (previous !== undefined) yield* forget(systemctl, previous.unit); + if (scopeByThread.size > MAX_TRACKED_THREADS) yield* evictOldest(systemctl); + }); - const oomKilled: AgentScopeShape["oomKilled"] = (threadId) => - Effect.gen(function* () { + const oomKilled: AgentScopeShape["oomKilled"] = Effect.fn("agentScope.oomKilled")( + function* (threadId) { const tracked = scopeByThread.get(threadId); if (tracked === undefined) return false; if (tracked.oomKilled) return true; @@ -301,14 +297,14 @@ export const make = Effect.gen(function* () { return true; } return false; - }); + }, + ); - const clear: AgentScopeShape["clear"] = (threadId) => - Effect.gen(function* () { - if (!scopeByThread.has(threadId)) return; - const scope = yield* systemd; - if (scope !== undefined) yield* track(scope.systemctl, threadId, undefined); - }); + const clear: AgentScopeShape["clear"] = Effect.fn("agentScope.clear")(function* (threadId) { + if (!scopeByThread.has(threadId)) return; + const scope = yield* systemd; + if (scope !== undefined) yield* track(scope.systemctl, threadId, undefined); + }); return { wrap: Effect.fn("AgentScope.wrap")(function* ({ @@ -318,21 +314,32 @@ export const make = Effect.gen(function* () { threadId, env, survivesRestart, + shared, }) { const resolved = yield* resolve(command, env ?? hostEnvironment); if (resolved === null) return { command, args }; const scope = yield* systemd; if (scope === undefined) return agentScopeCommand({ command: resolved, args }); const unit = `t3code-${name}-${NodeCrypto.randomUUID().slice(0, 8)}.scope`; - if (threadId !== undefined) yield* track(scope.systemctl, threadId, unit); + // A continuing shared scope cannot attribute a tool's OOM to one thread. + // Clear the launch thread's previous scope instead. + const continuing = shared === true && scope.supportsOomPolicy; + const owned = threadId !== undefined && !continuing; + if (threadId !== undefined) yield* track(scope.systemctl, threadId, owned ? unit : undefined); const systemdScope: AgentSystemdScope = { systemdRun: scope.systemdRun, runtimeDir: scope.runtimeDir, slice: survivesRestart === true ? AGENT_SLICE : ownedSlice, unit, - // Without a thread nothing reads the result, so let systemd unload - // the unit even when it fails. - options: threadId === undefined ? [...scope.scopeOptions, "--collect"] : scope.scopeOptions, + // Without an owning thread nothing reads the result, so let systemd + // unload the unit even when it fails. + options: [ + STOP_TIMEOUT_OPTION, + ...(scope.supportsOomPolicy + ? [`--property=OOMPolicy=${continuing ? "continue" : "stop"}`] + : []), + ...(owned ? [] : ["--collect"]), + ], }; return agentScopeCommand({ command: resolved, args, scope: systemdScope }); }), diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 9c0935de308d..73ff1b067e87 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1,7 +1,4 @@ // @effect-diagnostics nodeBuiltinImport:off - the Windows launch smoke tests use filesystem notifications to observe real PowerShell launches. -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -13,9 +10,9 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -29,6 +26,7 @@ import * as ExternalLauncher from "./externalLauncher.ts"; const windowsHost = HostProcess.Platform.defaultValue() === "win32"; interface MockSpawnResult { + readonly spawnError?: PlatformError.PlatformError; readonly exitCode?: number; readonly stdout?: string; readonly stall?: boolean; @@ -69,995 +67,251 @@ const layerTest = (input: { const layerSpawner = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => - Effect.sync(() => { + Effect.gen(function* () { assert.equal(ChildProcess.isStandardCommand(command), true); if (!ChildProcess.isStandardCommand(command)) { throw new Error("Expected a standard command"); - } - input.onSpawn?.(command); - return makeMockDetachedHandle({ - ...(input.onUnref === undefined ? {} : { onUnref: input.onUnref }), - ...input.spawnResult?.(command), - }); - }), - ), - ); - - return Layer.mergeAll( - ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, layerSpawner))), - Layer.succeed(HostProcess.Platform, input.platform), - Layer.succeed( - SpawnExecutableResolution, - (command) => input.resolveExecutable?.(command) ?? command, - ), - ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), - ); -}; - -it.effect("launches the default browser through the platform command", () => { - let spawned: ChildProcess.StandardCommand | undefined; - let didUnref = false; - return Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - - yield* launcher.launchBrowser("https://example.com/some path"); - - assert.ok(spawned); - assert.equal(spawned.command, "xdg-open"); - assert.deepEqual(spawned.args, ["https://example.com/some path"]); - assert.equal(spawned.options.detached, true); - assert.equal(didUnref, true); - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - onSpawn: (command) => { - spawned = command; - }, - onUnref: () => { - didUnref = true; - }, - }), - ), - ); -}); - -it.effect.skipIf(windowsHost)( - "launched browsers and editors do not inherit ELECTRON_RUN_AS_NODE", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - const codePath = path.join(binDir, "code"); - yield* fileSystem.writeFileString(codePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(codePath, 0o755); - - const spawned: ChildProcess.StandardCommand[] = []; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchBrowser("https://example.com"); - yield* launcher.launchEditor({ editor: "vscode", cwd: "/workspace" }); - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { PATH: binDir, DISPLAY: ":0" }, - onSpawn: (command) => spawned.push(command), - }), - ), - Effect.provideService(HostProcess.Environment, { - PATH: binDir, - DISPLAY: ":0", - ELECTRON_RUN_AS_NODE: "1", - }), - ); - - assert.deepEqual( - spawned.map((command) => ({ - command: command.command, - env: command.options.env, - extendEnv: command.options.extendEnv, - })), - ["xdg-open", "code"].map((command) => ({ - command, - env: { PATH: binDir, DISPLAY: ":0" }, - extendEnv: false, - })), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.each(["win32", "linux"] as const)( - "launches the browser through PowerShell on %s", - (platform) => { - let spawned: ChildProcess.StandardCommand | undefined; - return Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - - yield* launcher.launchBrowser("https://example.com/some path"); - - assert.ok(spawned); - assert.equal( - spawned.command, - platform === "win32" - ? "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" - : "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", - ); - assert.deepEqual(spawned.args.slice(0, -1), [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-EncodedCommand", - ]); - const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; - const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); - assert.equal( - decodedCommand, - "$ProgressPreference = 'SilentlyContinue'; Start 'https://example.com/some path'", - ); - assert.equal(spawned.options.detached, platform !== "win32"); - }).pipe( - Effect.provide( - layerTest({ - platform, - env: { SYSTEMROOT: "C:\\Windows", WSL_DISTRO_NAME: "Ubuntu" }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - }, -); - -it.effect("launches an installed editor with platform-safe arguments", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); - - let spawned: ChildProcess.StandardCommand | undefined; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ - editor: "vscode", - cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", - }); - }).pipe( - Effect.provide( - layerTest({ - platform: "win32", - env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - resolveExecutable: (command) => - command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - - assert.ok(spawned); - assert.equal( - spawned.command, - [ - '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"', - '^"--goto^"', - '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"', - ].join(" "), - ); - assert.deepEqual(spawned.args, []); - assert.equal(spawned.options.shell, true); - assert.equal(spawned.options.detached, true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -for (const platform of ["darwin", "linux"] as const) { - it.effect.skipIf(windowsHost)(`launches Cursor in classic IDE mode on ${platform}`, () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - const cursorPath = path.join(binDir, "cursor"); - yield* fileSystem.writeFileString(cursorPath, "#!/bin/sh\n"); - yield* fileSystem.chmod(cursorPath, 0o755); - - const spawned: ChildProcess.StandardCommand[] = []; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - for (const cwd of [ - "/workspace with spaces", - "/workspace with spaces/src/index.ts", - "/workspace with spaces/src/index.ts:12", - "/workspace with spaces/src/index.ts:12:4", - ]) { - yield* launcher.launchEditor({ editor: "cursor", cwd }); - } - }).pipe( - Effect.provide( - layerTest({ - platform, - env: { PATH: binDir }, - onSpawn: (command) => spawned.push(command), - }), - ), - ); - - assert.deepEqual( - spawned.map((command) => ({ command: command.command, args: command.args })), - [ - { command: "cursor", args: ["--classic", "/workspace with spaces"] }, - { command: "cursor", args: ["--classic", "/workspace with spaces/src/index.ts"] }, - { - command: "cursor", - args: ["--classic", "--goto", "/workspace with spaces/src/index.ts:12"], - }, - { - command: "cursor", - args: ["--classic", "--goto", "/workspace with spaces/src/index.ts:12:4"], - }, - ], - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); -} - -it.effect("launches Cursor in classic IDE mode through the Windows command shim", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - yield* fileSystem.writeFileString(path.join(binDir, "cursor.CMD"), "@echo off\r\n"); - - let spawned: ChildProcess.StandardCommand | undefined; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ - editor: "cursor", - cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", - }); - }).pipe( - Effect.provide( - layerTest({ - platform: "win32", - env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - resolveExecutable: (command) => - command === "cursor" ? "C:\\Program Files\\Cursor\\bin\\cursor.CMD" : command, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - - assert.ok(spawned); - assert.equal( - spawned.command, - [ - '^"C:\\Program^ Files\\Cursor\\bin\\cursor.CMD^"', - '^"--classic^"', - '^"--goto^"', - '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"', - ].join(" "), - ); - assert.deepEqual(spawned.args, []); - assert.equal(spawned.options.shell, true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect("opens a folder in File Explorer with Windows separators", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - yield* fileSystem.writeFileString(path.join(binDir, "explorer.EXE"), ""); - - let spawned: ChildProcess.StandardCommand | undefined; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ editor: "file-manager", cwd: "C:/workspace/project" }); - }).pipe( - Effect.provide( - layerTest({ - platform: "win32", - env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - - assert.ok(spawned); - assert.deepEqual(spawned.args, ["C:\\workspace\\project"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)("reveals a file in Finder with open -R on macOS", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - const openPath = path.join(binDir, "open"); - yield* fileSystem.writeFileString(openPath, "#!/bin/sh\n"); - yield* fileSystem.chmod(openPath, 0o755); - - let spawned: ChildProcess.StandardCommand | undefined; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: "/workspace/media/linux-mini-v2.mp4", - reveal: true, - }); - }).pipe( - Effect.provide( - layerTest({ - platform: "darwin", - env: { PATH: binDir }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - - assert.ok(spawned); - assert.equal(spawned.command, "open"); - assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]); - assert.equal(spawned.options.detached, true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect("reveals a file in File Explorer through PowerShell on Windows", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); - // resolvePowerShellPath builds `${SYSTEMROOT}\System32\...` with Windows - // separators, which on the posix test filesystem is one file name. - const systemRoot = path.join(binDir, "system-root"); - const powerShellPath = `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; - yield* fileSystem.makeDirectory(path.dirname(powerShellPath), { recursive: true }); - yield* fileSystem.writeFileString(powerShellPath, ""); - - let spawned: ChildProcess.StandardCommand | undefined; - const kind = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: "C:/workspace with spaces/media/author's clip.mp4", - reveal: true, - }); - return yield* launcher.resolveFileManagerRevealKind(); - }).pipe( - Effect.provide( - layerTest({ - platform: "win32", - env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD", SYSTEMROOT: systemRoot }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - - assert.equal(kind, "file-explorer"); - assert.ok(spawned); - assert.equal(spawned.command, powerShellPath); - assert.deepEqual(spawned.args.slice(0, -1), [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-EncodedCommand", - ]); - const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; - const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); - assert.equal( - decodedCommand, - "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')", - ); - assert.equal(spawned.options.shell, false); - assert.equal(spawned.options.detached, false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -for (const kind of ["browser", "reveal"] as const) { - it.live.skipIf(!windowsHost)( - `completes a ${kind} launch through real PowerShell after the launcher scope closes`, - () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-")); - const outputPath = NodePath.join(tempDir, "argv.txt"); - const watcher = NodeFS.watch(tempDir, { signal: AbortSignal.timeout(20_000) }); - try { - const recorderPath = NodePath.join(tempDir, "recorder.cmd"); - const stagingPath = NodePath.join(tempDir, "argv.tmp"); - NodeFS.writeFileSync( - recorderPath, - `@echo off\r\n>"${stagingPath}" echo(%*\r\nmove /y "${stagingPath}" "${outputPath}" >nul\r\n`, - ); - const recorded = new Promise((resolve, reject) => { - watcher.on("change", () => { - if (NodeFS.existsSync(outputPath)) { - resolve(NodeFS.readFileSync(outputPath, "utf8").trim()); - } - }); - watcher.on("error", reject); - watcher.on("close", () => - reject(new Error("PowerShell did not run the recording stub")), - ); - }); - - const target = "C:/workspace with spaces/media/author's clip.mp4"; - yield* Effect.all( - [ - Effect.gen(function* () { - const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const recordingSpawner = ChildProcessSpawner.make((command) => { - assert.ok(ChildProcess.isStandardCommand(command)); - const source = Buffer.from(command.args.at(-1) ?? "", "base64") - .toString("utf16le") - .replace("'explorer.exe'", `'${recorderPath.replaceAll("'", "''")}'`); - return realSpawner.spawn( - ChildProcess.make( - command.command, - [ - ...command.args.slice(0, -1), - Buffer.from(source, "utf16le").toString("base64"), - ], - command.options, - ), - ); - }); - const launcher = yield* ExternalLauncher.make.pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, recordingSpawner), - ); - if (kind === "browser") { - yield* launcher.launchBrowser(recorderPath); - } else { - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: target, - reveal: true, - }); - } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - Effect.promise(() => - recorded.then((value) => { - assert.equal( - value, - kind === "browser" ? "" : `/select,"${target.replaceAll("/", "\\")}"`, - ); - }), - ), - ], - { concurrency: "unbounded" }, - ); - } finally { - watcher.close(); - // The recording stub's cmd.exe can still hold the directory for a - // moment after it publishes its output, which Windows reports as EPERM. - NodeFS.rmSync(tempDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); - } - }), - { timeout: 30_000 }, - ); -} - -it.effect("does not advertise reveal on Windows when PowerShell is missing", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); - - const result = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return { - kind: yield* launcher.resolveFileManagerRevealKind(), - editors: yield* launcher.resolveAvailableEditors(), - }; - }).pipe( - Effect.provide( - layerTest({ - platform: "win32", - env: { - PATH: binDir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - SYSTEMROOT: path.join(binDir, "missing-system-root"), - }, - }), - ), - ); - - assert.equal(result.editors.includes("file-manager"), true); - assert.isUndefined(result.kind); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)( - "reveals a WSL file in Windows File Explorer through its UNC path", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["explorer.exe", "powershell.exe", "xdg-open"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - - let spawned: ChildProcess.StandardCommand | undefined; - const result = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - const kind = yield* launcher.resolveFileManagerRevealKind(); - const editors = yield* launcher.resolveAvailableEditors(); - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: "/home/t3/workspace/media/clip.mp4", - reveal: true, - }); - return { kind, editors }; - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { - PATH: binDir, - WSL_DISTRO_NAME: "Ubuntu-24.04", - WSL_INTEROP: "/run/WSL/1_interop", - }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - - assert.equal(result.kind, "file-explorer"); - assert.equal(result.editors.includes("file-manager"), true); - assert.ok(spawned); - assert.equal(spawned.command, "powershell.exe"); - assert.equal(spawned.options.detached, true); - const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; - const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); - assert.equal( - decodedCommand, - "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace\\media\\clip.mp4' + '\"')", - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)( - "does not advertise reveal from WSL when interop PowerShell is missing", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - const explorerPath = path.join(binDir, "explorer.exe"); - yield* fileSystem.writeFileString(explorerPath, ""); - yield* fileSystem.chmod(explorerPath, 0o755); - - const result = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return { - kind: yield* launcher.resolveFileManagerRevealKind(), - editors: yield* launcher.resolveAvailableEditors(), - }; - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { - PATH: binDir, - WSL_DISTRO_NAME: "Ubuntu-24.04", - WSL_INTEROP: "/run/WSL/1_interop", - }, - }), - ), - ); - - assert.equal(result.editors.includes("file-manager"), true); - assert.isUndefined(result.kind); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)( - "reveals through the Linux file manager when WSL lacks interop PowerShell", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["explorer.exe", "xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - - const spawnedCommands: ChildProcess.StandardCommand[] = []; - const kind = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - const revealKind = yield* launcher.resolveFileManagerRevealKind(); - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: "/home/t3/workspace/media/clip.mp4", - reveal: true, - }); - return revealKind; - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { - PATH: binDir, - WSL_DISTRO_NAME: "Ubuntu-24.04", - WSL_INTEROP: "/run/WSL/1_interop", - DISPLAY: ":0", - }, - onSpawn: (command) => { - spawnedCommands.push(command); - }, - spawnResult: (command) => - command.command === "xdg-mime" - ? { stdout: "org.gnome.Nautilus.desktop\n" } - : undefined, - }), - ), - ); - - assert.equal(kind, "files"); - const launch = spawnedCommands.find((command) => command.command === "xdg-open"); - assert.ok(launch); - assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); - assert.isUndefined(spawnedCommands.find((command) => command.command === "explorer.exe")); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)( - "falls back to the Linux file manager when WSL lacks the Explorer bridge", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - - const spawnedCommands: ChildProcess.StandardCommand[] = []; - const result = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - const editors = yield* launcher.resolveAvailableEditors(); - const kind = yield* launcher.resolveFileManagerRevealKind(); - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: "/home/t3/workspace/media/clip.mp4", - reveal: true, - }); - return { editors, kind }; - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { - PATH: binDir, - WSL_DISTRO_NAME: "Ubuntu-24.04", - WSL_INTEROP: "/run/WSL/1_interop", - DISPLAY: ":0", - }, - onSpawn: (command) => { - spawnedCommands.push(command); - }, - spawnResult: (command) => - command.command === "xdg-mime" - ? { stdout: "org.gnome.Nautilus.desktop\n" } - : undefined, - }), - ), - ); - - assert.equal(result.editors.includes("file-manager"), true); - assert.equal(result.kind, "files"); - const launch = spawnedCommands.find((command) => command.command === "xdg-open"); - assert.ok(launch); - assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)( - "falls back to opening the containing directory for WSL paths Explorer cannot select", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["explorer.exe", "powershell.exe"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - - let spawned: ChildProcess.StandardCommand | undefined; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: '/home/t3/work "quoted"/clip.mp4', - reveal: true, - }); - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { - PATH: binDir, - WSL_DISTRO_NAME: "Ubuntu-24.04", - WSL_INTEROP: "/run/WSL/1_interop", - }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - - assert.ok(spawned); - assert.equal(spawned.command, "explorer.exe"); - assert.deepEqual(spawned.args, ['\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\work "quoted"']); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)("reveals by opening the containing directory on Linux", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - - const spawnedCommands: ChildProcess.StandardCommand[] = []; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: "/workspace/media/linux-mini-v2.mp4", - reveal: true, - }); - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { PATH: binDir, DISPLAY: ":0" }, - onSpawn: (command) => { - spawnedCommands.push(command); - }, - spawnResult: (command) => - command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, - }), - ), - ); - - const spawned = spawnedCommands.find((command) => command.command === "xdg-open"); - assert.ok(spawned); - assert.deepEqual(spawned.args, ["/workspace/media"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)( - "does not advertise a Linux file manager without a graphical session", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - const xdgOpenPath = path.join(binDir, "xdg-open"); - yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); - yield* fileSystem.chmod(xdgOpenPath, 0o755); - - const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); - }).pipe(Effect.provide(layerTest({ platform: "linux", env: { PATH: binDir } }))); - - assert.equal(editors.includes("file-manager"), false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)( - "advertises a Linux file manager when a directory handler is installed", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - - let probe: ChildProcess.StandardCommand | undefined; - const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { PATH: binDir, DISPLAY: ":0" }, - onSpawn: (command) => { - probe = command; - }, - spawnResult: (command) => - command.command === "xdg-mime" - ? { stdout: "org.gnome.Nautilus.desktop\n" } - : undefined, - }), - ), - ); - - assert.equal(editors.includes("file-manager"), true); - assert.ok(probe); - assert.equal(probe.command, "xdg-mime"); - assert.deepEqual(probe.args, ["query", "default", "inode/directory"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)( - "does not advertise a Linux file manager without a directory handler", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - - const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { PATH: binDir, DISPLAY: ":0" }, - spawnResult: (command) => (command.command === "xdg-mime" ? { stdout: "" } : undefined), - }), - ), - ); + } + input.onSpawn?.(command); + const result = input.spawnResult?.(command); + if (result?.spawnError) return yield* result.spawnError; + return makeMockDetachedHandle({ + ...(input.onUnref === undefined ? {} : { onUnref: input.onUnref }), + ...result, + }); + }), + ), + ); - assert.equal(editors.includes("file-manager"), false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); + return Layer.mergeAll( + ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, layerSpawner))), + Layer.succeed(HostProcess.Platform, input.platform), + Layer.succeed( + SpawnExecutableResolution, + (command) => input.resolveExecutable?.(command) ?? command, + ), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), + ); +}; it.effect.skipIf(windowsHost)( - "does not advertise a Linux file manager when the handler query fails", + "launched browsers and editors do not inherit ELECTRON_RUN_AS_NODE", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } + const codePath = path.join(binDir, "code"); + yield* fileSystem.writeFileString(codePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(codePath, 0o755); - const editors = yield* Effect.gen(function* () { + const spawned: ChildProcess.StandardCommand[] = []; + yield* Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); + yield* launcher.launchBrowser("https://example.com"); + yield* launcher.launchEditor({ editor: "vscode", cwd: "/workspace" }); }).pipe( Effect.provide( layerTest({ platform: "linux", env: { PATH: binDir, DISPLAY: ":0" }, - spawnResult: (command) => - command.command === "xdg-mime" - ? { exitCode: 47, stdout: "org.gnome.Nautilus.desktop\n" } - : undefined, + onSpawn: (command) => spawned.push(command), }), ), + Effect.provideService(HostProcess.Environment, { + PATH: binDir, + DISPLAY: ":0", + ELECTRON_RUN_AS_NODE: "1", + }), ); - assert.equal(editors.includes("file-manager"), false); + assert.deepEqual( + spawned.map((command) => ({ + command: command.command, + env: command.options.env, + extendEnv: command.options.extendEnv, + })), + ["xdg-open", "code"].map((command) => ({ + command, + env: { PATH: binDir, DISPLAY: ":0" }, + extendEnv: false, + })), + ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -// Runs on the live clock so the probe's real timeout fires. -it.live.skipIf(windowsHost)("a stalled handler probe drops only the file manager", () => +it.effect("launches an installed editor with platform-safe arguments", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime", "code"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } + yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); - const editors = yield* Effect.gen(function* () { + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); + yield* launcher.launchEditor({ + editor: "vscode", + cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", + }); }).pipe( Effect.provide( layerTest({ - platform: "linux", - env: { PATH: binDir, DISPLAY: ":0" }, - spawnResult: (command) => (command.command === "xdg-mime" ? { stall: true } : undefined), + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + resolveExecutable: (command) => + command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command, + onSpawn: (command) => { + spawned = command; + }, }), ), ); - assert.equal(editors.includes("vscode"), true); - assert.equal(editors.includes("file-manager"), false); + assert.ok(spawned); + assert.equal( + spawned.command, + [ + '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"', + '^"--goto^"', + '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"', + ].join(" "), + ); + assert.deepEqual(spawned.args, []); + assert.equal(spawned.options.shell, true); + assert.equal(spawned.options.detached, true); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +for (const {} of ["darwin", "linux"] as const) { +} + +for (const {} of [ + { kind: "browser", folderVerb: "none" }, + { kind: "reveal", folderVerb: "none" }, + { kind: "reveal", folderVerb: "" }, + { kind: "reveal", folderVerb: "open" }, +] as const) { +} + it.effect.skipIf(windowsHost)( - "does not advertise a Linux file manager when xdg-mime is missing", + "reveals a WSL file in Windows File Explorer through its UNC path", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - const xdgOpenPath = path.join(binDir, "xdg-open"); - yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); - yield* fileSystem.chmod(xdgOpenPath, 0o755); + for (const name of ["explorer.exe", "powershell.exe", "xdg-open"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } - const editors = yield* Effect.gen(function* () { + let spawned: ChildProcess.StandardCommand | undefined; + const result = yield* Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); + const kind = yield* launcher.resolveFileManagerRevealKind(); + const editors = yield* launcher.resolveAvailableEditors(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace [draft]/author's $media/clip|draft.mp4", + reveal: true, + }); + return { kind, editors }; }).pipe( - Effect.provide(layerTest({ platform: "linux", env: { PATH: binDir, DISPLAY: ":0" } })), + Effect.provide( + layerTest({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), ); - assert.equal(editors.includes("file-manager"), false); + assert.equal(result.kind, "file-explorer"); + assert.equal(result.editors.includes("file-manager"), true); + assert.ok(spawned); + assert.equal(spawned.command, "powershell.exe"); + assert.equal(spawned.options.detached, true); + const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; + const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); + assert.equal( + decodedCommand, + "$ProgressPreference = 'SilentlyContinue'; $folderVerb = (Get-ItemProperty -LiteralPath 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell' -ErrorAction SilentlyContinue).'(default)'; if ($folderVerb -and $folderVerb -ne 'none') { Invoke-Item -LiteralPath '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace [draft]\\author''s $media' } else { Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace [draft]\\author''s $media\\clip|draft.mp4' + '\"') }", + ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("discovers editors through the service API", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); - yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); +for (const wsl of [false, true]) { + it.effect.skipIf(windowsHost)( + "selects a file over D-Bus on " + (wsl ? "WSLg without interop PowerShell" : "Linux"), + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime", "gdbus", ...(wsl ? ["explorer.exe"] : [])]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + const target = "/workspace/media/a 'quoted' \"clip\" #100% ? é\\name.mp4"; + const spawnedCommands: ChildProcess.StandardCommand[] = []; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ editor: "file-manager", cwd: target, reveal: true }); + }).pipe( + Effect.provide( + layerTest({ + platform: "linux", + env: { + PATH: binDir, + WAYLAND_DISPLAY: "wayland-1", + ...(wsl + ? { WSL_DISTRO_NAME: "Ubuntu-24.04", WSL_INTEROP: "/run/WSL/1_interop" } + : {}), + }, + onSpawn: (command) => spawnedCommands.push(command), + spawnResult: (command) => + command.command === "xdg-mime" + ? { stdout: "com.thisisgm.flea.desktop\n" } + : undefined, + }), + ), + ); - const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); - }).pipe( - Effect.provide( - layerTest({ - platform: "win32", - env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - }), - ), - ); + const reveal = spawnedCommands.find((command) => command.command === "gdbus"); + assert.ok(reveal); + assert.deepEqual(reveal.args, [ + "call", + "--session", + "--dest", + "org.freedesktop.FileManager1", + "--object-path", + "/org/freedesktop/FileManager1", + "--method", + "org.freedesktop.FileManager1.ShowItems", + "[\"file:///workspace/media/a%20'quoted'%20%22clip%22%20%23100%25%20%3F%20%C3%A9%5Cname.mp4\"]", + "", + ]); + assert.deepEqual( + spawnedCommands.filter((command) => command.command !== "xdg-mime"), + [reveal], + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +} - assert.equal(editors.includes("vscode"), true); - assert.equal(editors.includes("file-manager"), true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); +for (const {} of ["rejected", "spawn-error", "stalled"] as const) { +} -for (const { platform, installPath, editor, args } of [ +for (const {} of [ { platform: "darwin", installPath: "Applications/Antigravity IDE.app/Contents/Resources/app/bin/antigravity-ide", @@ -1086,7 +340,7 @@ for (const { platform, installPath, editor, args } of [ platform: "darwin", installPath: "Applications/WebStorm.app/Contents/MacOS/webstorm", editor: "webstorm", - args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"], + args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], }, { platform: "darwin", @@ -1110,13 +364,13 @@ for (const { platform, installPath, editor, args } of [ platform: "win32", installPath: "Programs/JetBrains/WebStorm 2026.2/bin/webstorm64.exe", editor: "webstorm", - args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"], + args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], }, { platform: "win32", installPath: "Programs/WebStorm/bin/webstorm64.exe", editor: "webstorm", - args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"], + args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], }, { platform: "win32", @@ -1128,7 +382,7 @@ for (const { platform, installPath, editor, args } of [ platform: "linux", installPath: ".local/share/JetBrains/Toolbox/scripts/idea", editor: "idea", - args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"], + args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], }, { platform: "linux", @@ -1137,43 +391,6 @@ for (const { platform, installPath, editor, args } of [ args: ["/workspace with spaces/file.ts:12:4"], }, ] as const) { - it.effect.skipIf(windowsHost && platform !== "win32")( - `discovers and launches ${editor} outside PATH on ${platform}`, - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor installs-" }); - const executable = path.join(home, installPath); - yield* fs.makeDirectory(path.dirname(executable), { recursive: true }); - yield* fs.writeFileString(executable, "#!/bin/sh\n"); - yield* fs.chmod(executable, 0o755); - let spawned: ChildProcess.StandardCommand | undefined; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - assert.include(yield* launcher.resolveAvailableEditors(), editor); - yield* launcher.launchEditor({ editor, cwd: "/workspace with spaces/file.ts:12:4" }); - }).pipe( - Effect.provide( - layerTest({ - platform, - env: { HOME: home, LOCALAPPDATA: home, PATH: path.join(home, "empty") }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - assert.ok(spawned); - const shim = executable.endsWith(".cmd"); - assert.equal( - spawned.command, - shim ? [`^"${executable.replaceAll(" ", "^ ")}^"`, ...args].join(" ") : executable, - ); - assert.deepEqual(spawned.args, shim ? [] : args); - assert.equal(spawned.options.shell, shim); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); } for (const cwd of ["/workspace with spaces/", "/workspace with spaces/file.ts:12:4"]) { @@ -1255,7 +472,7 @@ it.effect.each([ "--line", "12", "--column", - "4", + "3", "/workspace with spaces/file.ts", ]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), @@ -1420,63 +637,6 @@ it.effect.skipIf(windowsHost)("ignores unusable app bundles and keeps PATH launc }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("memoizes editor discovery and refreshes after the cache window", () => { - let statCalls = 0; - const fileInfo = { type: "File" } as FileSystem.File.Info; - const layerLauncher = ExternalLauncher.layer.pipe( - Layer.provide( - Layer.mergeAll( - FileSystem.layerNoop({ - stat: () => - Effect.sync(() => { - statCalls += 1; - return fileInfo; - }), - }), - Path.layer, - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())), - ), - ), - ), - ); - - return Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - - const first = yield* launcher.resolveAvailableEditors(); - assert.equal(first.includes("vscode"), true); - const statCallsAfterFirstScan = statCalls; - assert.isAbove(statCallsAfterFirstScan, 0); - - yield* TestClock.adjust("31 seconds"); - const second = yield* launcher.resolveAvailableEditors(); - assert.deepEqual([...second], [...first]); - assert.equal(statCalls, statCallsAfterFirstScan); - - yield* TestClock.adjust("30 seconds"); - yield* launcher.resolveAvailableEditors(); - assert.isAbove(statCalls, statCallsAfterFirstScan); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerLauncher, - Layer.succeed(HostProcess.Platform, "win32"), - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - PATH: "C:\\t3-editor-discovery-cache-test", - PATHEXT: ".COM;.EXE;.BAT;.CMD", - }, - }), - ), - TestClock.layer(), - ), - ), - ); -}); - it.effect("keeps scanning after the caller is interrupted and shares that scan", () => { const fileInfo = { type: "File" } as FileSystem.File.Info; const release = Deferred.makeUnsafe(); @@ -1595,43 +755,3 @@ it.effect("starts a Windows command shim for unicode paths with cmd metacharacte ); }), ); - -it.effect.skipIf(windowsHost)("passes line breaks through to editors started without a shell", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - const zed = path.join(binDir, "zed"); - yield* fileSystem.writeFileString(zed, "#!/bin/sh\n"); - yield* fileSystem.chmod(zed, 0o755); - let spawned: ChildProcess.StandardCommand | undefined; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace/odd\nname.ts" }); - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { PATH: binDir, HOME: binDir }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - assert.ok(spawned); - assert.deepEqual(spawned.args, ["/workspace/odd\nname.ts"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect("rejects unknown editors through the service API", () => - Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - const error = yield* launcher - .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" }) - .pipe(Effect.flip); - assert.instanceOf(error, ExternalLauncher.ExternalLauncherUnknownEditorError); - assert.equal(error.editor, "missing-editor"); - assert.equal(error.message, "Unknown editor: missing-editor"); - }).pipe(Effect.provide(layerTest({ platform: "linux", env: { PATH: "" } }))), -); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 668e7f746900..1260f2e3fda8 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -1,3 +1,4 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import { EDITORS, ExternalLauncherError, @@ -162,9 +163,10 @@ function resolveCommandEditorArgs( onSome: ({ path, line, column }) => [ "--line", line, + // JetBrains IDEs count `--column` from 0; targets count columns from 1. ...Option.match(column, { onNone: () => [], - onSome: (value) => ["--column", value], + onSome: (value) => ["--column", String(Math.max(0, Number(value) - 1))], }), path, ], @@ -561,30 +563,24 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( }; }); -/** - * Explorer's contract is the single argument `/select,""` with only the - * path quoted; Node's default spawn quoting wraps the whole argument when the - * path has spaces and Explorer misparses it, silently opening a fallback - * folder. A single `-ArgumentList` string in Windows PowerShell 5.1 reaches the - * child's command line verbatim, preserving the raw switch. - */ -function buildFileExplorerRevealPowerShellSource(explorerCommand: string, target: string): string { - return `$ProgressPreference = 'SilentlyContinue'; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + ${escapePowerShellStringLiteral(target)} + '"')`; +// A replacement manager registers a default Directory shell verb; stock Windows +// uses "none". Explorer needs its /select argument passed verbatim through +// PowerShell because Node's argument quoting changes how Explorer parses it. +function buildFileManagerRevealPowerShellSource(target: string, parent: string): string { + const literal = escapePowerShellStringLiteral(target); + return `$ProgressPreference = 'SilentlyContinue'; $folderVerb = (Get-ItemProperty -LiteralPath 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell' -ErrorAction SilentlyContinue).'(default)'; if ($folderVerb -and $folderVerb -ne 'none') { Invoke-Item -LiteralPath ${escapePowerShellStringLiteral(parent)} } else { Start-Process 'explorer.exe' -ArgumentList ('/select,"' + ${literal} + '"') }`; } -function fileExplorerRevealLaunch( +function powerShellFileManagerLaunch( target: string, - explorerTarget: string, + source: string, powershellCommand: string, ): EditorLaunch { return { editor: "file-manager", target, command: powershellCommand, - args: [ - ...POWERSHELL_ARGUMENTS_PREFIX, - encodeUtf16LeBase64(buildFileExplorerRevealPowerShellSource("explorer.exe", explorerTarget)), - ], + args: [...POWERSHELL_ARGUMENTS_PREFIX, encodeUtf16LeBase64(source)], }; } @@ -607,10 +603,12 @@ const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch } if (platform === "win32") { + const path = yield* Path.Path.pipe(Effect.provide(NodePath.layerWin32)); + const windowsTarget = normalizeWindowsFileManagerPath(target); return { - ...fileExplorerRevealLaunch( + ...powerShellFileManagerLaunch( target, - normalizeWindowsFileManagerPath(target), + buildFileManagerRevealPowerShellSource(windowsTarget, path.dirname(windowsTarget)), resolvePowerShellPath(env), ), detached: WINDOWS_POWERSHELL_DETACHED, @@ -624,20 +622,15 @@ const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch ) { const explorerTarget = resolveWslFileManagerPath(target, env.WSL_DISTRO_NAME); if (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) { - // Explorer's raw switch cannot express a double quote, and unlike - // Windows paths a WSL path may legally contain one: open the containing - // directory in File Explorer instead, matching the advertised - // "file-explorer" kind. - if (explorerTarget.includes('"')) { - const path = yield* Path.Path; - return { - editor: "file-manager", - target, - command: "explorer.exe", - args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)], - }; - } - return fileExplorerRevealLaunch(target, explorerTarget, WSL_POWERSHELL_COMMAND); + // WSL names may contain quotes that Explorer's /select cannot encode. + // Open the parent through the shell so replacement managers still apply. + const path = yield* Path.Path; + // .NET path parsing rejects characters that are legal in WSL filenames. + const parent = resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME); + const source = explorerTarget.includes('"') + ? `Invoke-Item -LiteralPath ${escapePowerShellStringLiteral(parent)}` + : buildFileManagerRevealPowerShellSource(explorerTarget, parent); + return powerShellFileManagerLaunch(target, source, WSL_POWERSHELL_COMMAND); } if (hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env))) { const path = yield* Path.Path; @@ -652,12 +645,69 @@ const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch }; } - // Linux file managers have no portable "select this file" flag, so open - // the containing directory instead. const path = yield* Path.Path; return { editor: "file-manager", target, command, args: [path.dirname(target)] }; }); +const tryRevealLinuxItem = Effect.fn("externalLauncher.tryRevealLinuxItem")( + function* (target: string) { + const env = yield* readCommandLookupEnv; + if (!(yield* isCommandAvailable("gdbus", { env }))) return false; + + const path = yield* Path.Path; + const uri = yield* path.toFileUrl(target); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const exitCode = yield* spawner.exitCode( + ChildProcess.make( + "gdbus", + [ + "call", + "--session", + "--dest", + "org.freedesktop.FileManager1", + "--object-path", + "/org/freedesktop/FileManager1", + "--method", + "org.freedesktop.FileManager1.ShowItems", + // File URLs percent-encode the double quotes and backslashes GVariant would interpret. + `["${uri.href}"]`, + "", + ], + { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + // Timeout waits for scoped cleanup, even if the helper ignores SIGTERM. + forceKillAfter: "1 second", + }, + ), + ); + return exitCode === 0; + }, + Effect.timeout("2 seconds"), + Effect.catchTags({ + BadArgument: () => Effect.succeed(false), + PlatformError: () => Effect.succeed(false), + TimeoutError: () => Effect.succeed(false), + }), +); + +const launchEditor = Effect.fn("externalLauncher.launchEditor")(function* ( + input: LaunchEditorInput, +) { + const launch = yield* resolveEditorLaunch(input); + if ( + input.editor === "file-manager" && + input.reveal === true && + launch.command === "xdg-open" && + (yield* HostProcess.Platform) === "linux" && + (yield* tryRevealLinuxItem(launch.target)) + ) { + return; + } + yield* launchEditorProcess(launch); +}); + const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( launch: ProcessLaunch, onError: (cause: unknown) => ExternalLauncherError, @@ -817,9 +867,9 @@ export const make = Effect.gen(function* () { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), launchEditor: (input) => - provideCommandResolutionServices( - Effect.flatMap(resolveEditorLaunch(input), launchEditorProcess), - ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), + provideCommandResolutionServices(launchEditor(input)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), }); }); diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index 13e811baedfd..29d6dbd83659 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -1,13 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/process"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; @@ -79,50 +76,6 @@ const runWith = ); describe("runProcess", () => { - it.effect("collects stdout through an injected ChildProcessSpawner", () => - Effect.gen(function* () { - const spawner = makeSpawner((command) => - Effect.sync(() => { - expect(command.command).toBe("fake"); - expect(command.args).toEqual(["stdout-bytes", "32"]); - return makeHandle({ stdout: "x".repeat(32) }); - }), - ); - - const result = yield* runWith(spawner)({ - command: "fake", - args: ["stdout-bytes", "32"], - }); - - expect(result.code).toBe(0); - expect(result.stdout).toBe("x".repeat(32)); - expect(result.timedOut).toBe(false); - }), - ); - - it.effect("runs through the ProcessRunner service", () => { - const spawner = makeSpawner((command) => - Effect.sync(() => { - expect(command.command).toBe("fake"); - expect(command.args).toEqual(["--service"]); - return makeHandle({ stdout: "service ok" }); - }), - ); - const layer = ProcessRunner.layer.pipe( - Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), - ); - - return Effect.gen(function* () { - const runner = yield* ProcessRunner.ProcessRunner; - const result = yield* runner.run({ - command: "fake", - args: ["--service"], - }); - - expect(result.stdout).toBe("service ok"); - }).pipe(Effect.provide(layer)); - }); - it.effect("resolves and escapes Windows command shims before spawning", () => { const spawner = makeSpawner((command) => Effect.sync(() => { @@ -164,44 +117,6 @@ describe("runProcess", () => { ); }); - it.effect("preserves resolved spawn context and cause", () => - Effect.gen(function* () { - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "ChildProcessSpawner", - method: "spawn", - pathOrDescriptor: "/actual/fake", - }); - const spawner = makeSpawner(() => Effect.fail(cause)); - - const error = yield* runWith(spawner)({ - command: "fake", - args: ["--flag", "secret-token-value"], - cwd: "/logical", - spawnCwd: "/actual", - }).pipe(Effect.flip); - - expect(error._tag).toBe("ProcessSpawnError"); - if (error._tag !== "ProcessSpawnError") { - return expect.fail("Expected ProcessSpawnError"); - } - expect(error).toMatchObject({ - command: "fake", - argumentCount: 2, - cwd: "/logical", - spawnCwd: "/actual", - resolvedCommand: "fake", - resolvedArgumentCount: 2, - shell: false, - }); - expect(error.cause).toBe(cause); - expect(error.message).toBe("Failed to spawn process 'fake' in '/actual'"); - expect(error).not.toHaveProperty("args"); - expect(error).not.toHaveProperty("resolvedArgs"); - expect(error.message).not.toContain("secret-token-value"); - }), - ); - it.effect("types spawn failures that the platform spawner throws synchronously", () => Effect.gen(function* () { // A non-directory entry on PATH makes node's spawn throw ENOTDIR @@ -348,86 +263,8 @@ describe("runProcess", () => { }), ); - it.effect("returns output for non-zero exit codes", () => - Effect.gen(function* () { - const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stderr: "boom", code: 2 }))); - - const result = yield* runWith(spawner)({ - command: "fake", - args: ["stderr-exit", "boom", "2"], - }); - - expect(result.code).toBe(2); - expect(result.stderr).toBe("boom"); - }), - ); - - it.effect("fails on timeout", () => - Effect.gen(function* () { - const spawner = makeSpawner(() => - Effect.succeed( - makeHandle({ - exitCode: Effect.never, - }), - ), - ); - const errorFiber = yield* runWith(spawner)({ - command: "fake", - args: ["sleep"], - cwd: "/logical", - spawnCwd: "/actual", - timeout: "50 millis", - }).pipe(Effect.flip, Effect.forkScoped); - - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(50)); - const error = yield* Fiber.join(errorFiber); - - expect(error._tag).toBe("ProcessTimeoutError"); - if (error._tag !== "ProcessTimeoutError") { - return expect.fail("Expected ProcessTimeoutError"); - } - expect(error).toMatchObject({ - command: "fake", - argumentCount: 1, - cwd: "/logical", - spawnCwd: "/actual", - timeoutMs: 50, - }); - expect(error.message).toBe("Process 'fake' in '/actual' timed out after 50ms"); - }), - ); - - it.effect("returns a synthetic timed out result when timeoutBehavior is timedOutResult", () => - Effect.gen(function* () { - const spawner = makeSpawner(() => - Effect.succeed( - makeHandle({ - exitCode: Effect.never, - }), - ), - ); - const resultFiber = yield* runWith(spawner)({ - command: "fake", - args: ["sleep"], - timeout: "50 millis", - timeoutBehavior: "timedOutResult", - }).pipe(Effect.forkScoped); - - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(50)); - const result = yield* Fiber.join(resultFiber); - - expect(result).toMatchObject({ - stdout: "", - stderr: "", - code: null, - timedOut: true, - stdoutTruncated: false, - stderrTruncated: false, - }); - }), - ); + for (const {} of ["timeout", "success"] as const) { + } }); describe("isWindowsCommandNotFound", () => { @@ -441,11 +278,3 @@ describe("isWindowsCommandNotFound", () => { }), ); }); - -describe("commandName", () => { - it("drops the directory from POSIX and Windows paths", () => { - expect(ProcessRunner.commandName("/Users/me/.local/bin/claude")).toBe("claude"); - expect(ProcessRunner.commandName("C:\\Program Files\\nodejs\\npx.cmd")).toBe("npx.cmd"); - expect(ProcessRunner.commandName("git")).toBe("git"); - }); -}); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 3ee8232688cb..05640ec968d3 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -146,6 +146,8 @@ export class ProcessRunner extends Context.Service< >()("t3/processRunner") {} const DEFAULT_TIMEOUT = "60 seconds"; +// Keep SIGTERM-ignoring helpers from blocking timeouts or graceful shutdown. +const FORCE_KILL_AFTER = "1 second"; const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; export { isWindowsCommandNotFound } from "@t3tools/provider-core/server/snapshotProbe"; @@ -264,7 +266,7 @@ function finalizeRunProcess( ); } -export const commandName = (command: string) => command.replace(/^.*[\\/]/, ""); +const commandName = (command: string) => command.replace(/^.*[\\/]/, ""); const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], @@ -304,6 +306,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( } : {}), shell: spawnCommand.shell, + forceKillAfter: FORCE_KILL_AFTER, }), ) .pipe( diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 3a1ab5d0a7e6..be7457da4335 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -1,147 +1,390 @@ import { expect, it } from "@effect/vitest"; import { + EventId, ProjectId, + ProviderDriverKind, ProviderInstanceId, ThreadId, - type OrchestrationV2DomainEvent, + type ApplicationStoredEvent, + type OrchestrationV2AppThread, + type OrchestrationV2ProviderThread, } from "@t3tools/contracts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/sql/SqlClient"; import * as EventSink from "../orchestration-v2/EventSink.ts"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as EventStore from "../orchestration-v2/EventStore.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import { + CLAUDE_MODEL_SELECTION, + CODEX_MODEL_SELECTION, +} from "../orchestration-v2/testkit/fixtures/shared.ts"; import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as AgentSessionImporter from "./AgentSessionImporter.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; import * as ProjectService from "./ProjectService.ts"; const projectId = ProjectId.make("agent-session-import-project"); -const providerInstanceId = ProviderInstanceId.make("codex"); -const providerSessionId = "native-codex-thread"; -const threadId = ThreadId.make(`import:${providerInstanceId}:${providerSessionId}`); +const timestamp = "2026-09-01T10:00:00.000Z"; +const now = DateTime.makeUnsafe(timestamp); +const stores = Layer.mergeAll(EventStore.layer, ProjectionStore.layer).pipe( + Layer.provideMerge(SqlitePersistence.layerMemory), +); +const persistence = Layer.mergeAll( + stores, + EventSink.layer.pipe(Layer.provide(stores)), + ProviderSessionRuntime.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + IdAllocator.layer, +); + +function candidate( + modelSelection: typeof CODEX_MODEL_SELECTION, + providerSessionId: string, + provider: "codex" | "claudeAgent" = "codex", +) { + const providerInstanceId = modelSelection.instanceId; + return { + _tag: "Importable" as const, + source: { + provider, + providerInstanceId, + providerSessionId, + filePath: `/tmp/${providerInstanceId}/${providerSessionId}.jsonl`, + size: 100, + mtimeMs: 2, + device: 3, + inode: 4, + birthtimeMs: 1, + }, + thread: { + source: provider, + providerInstanceId, + providerSessionId, + title: "Imported thread", + model: modelSelection.model, + createdAt: timestamp, + updatedAt: timestamp, + messages: [ + { role: "user" as const, text: "Fix it", createdAt: timestamp }, + { role: "assistant" as const, text: "Fixed", createdAt: timestamp }, + ], + }, + } satisfies AgentSessionScanner.AgentSessionRecentThread; +} + +function importId(source: ReturnType) { + return ThreadId.make( + `import:${source.source.providerInstanceId}:${source.source.providerSessionId}`, + ); +} + +const seedOwner = Effect.fn(function* ( + source: ReturnType, + lifecycle: "active" | "archived" | "deleted" = "active", +) { + const sink = yield* EventSink.EventSinkV2; + const ids = yield* IdAllocator.IdAllocatorV2; + const { providerInstanceId, providerSessionId, provider } = source.source; + const threadId = ThreadId.make(`owner:${providerInstanceId}:${providerSessionId}`); + const driver = ProviderDriverKind.make(provider); + const providerThread: OrchestrationV2ProviderThread = { + id: ids.derive.providerThread({ + driver, + nativeThreadId: providerSessionId, + }), + driver, + providerInstanceId, + providerSessionId: null, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: providerSessionId, strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + const thread: OrchestrationV2AppThread = { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId, + title: "Native thread", + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: source.thread.model }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: lifecycle === "archived" ? now : null, + deletedAt: lifecycle === "deleted" ? now : null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + }; + yield* sink.write({ + events: [ + { + id: EventId.make(`${threadId}:created`), + type: "thread.created", + threadId, + occurredAt: now, + payload: thread, + }, + { + id: EventId.make(`${threadId}:provider`), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: providerThread, + }, + ], + }); + return threadId; +}); -it.effect("imports messages once and preserves the provider native resume binding", () => { - const writes: Array> = []; - const upserts: Array = []; - const recorded: Array = []; - let imported = false; - const scanner = AgentSessionScanner.AgentSessionScanner.of({ - scan: Effect.die("unused"), - recentThreads: () => - Stream.succeed({ - _tag: "Importable", - source: { - provider: "codex", - providerInstanceId, - providerSessionId, - filePath: "/tmp/native-codex-thread.jsonl", - size: 100, - mtimeMs: 2, - device: 3, - inode: 4, - birthtimeMs: 1, - }, - thread: { - source: "codex", - providerInstanceId, - providerSessionId, - title: "Imported thread", - model: "gpt-5.4", - createdAt: "2026-09-01T10:00:00.000Z", - updatedAt: "2026-09-01T10:01:00.000Z", - messages: [ - { role: "user", text: "Fix it", createdAt: "2026-09-01T10:00:00.000Z" }, - { role: "assistant", text: "Fixed", createdAt: "2026-09-01T10:01:00.000Z" }, - ], - }, - }), +const makeImporter = Effect.fn(function* ( + candidates: ReadonlyArray>, + beforeTransaction: Effect.Effect = Effect.void, +) { + const sql = yield* SqlClient.SqlClient; + const events = yield* EventStore.EventStoreV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const published: Array = []; + const withTransaction: typeof sql.withTransaction = (effect) => + beforeTransaction.pipe(Effect.andThen(sql.withTransaction(effect))); + const importSql = new Proxy(sql, { + get: (target, key, receiver) => + key === "withTransaction" ? withTransaction : Reflect.get(target, key, receiver), }); - const layerTest = AgentSessionImporter.layer.pipe( + const sinkLayer = Layer.fresh(EventSink.layer).pipe( Layer.provide( Layer.mergeAll( - Layer.succeed(AgentSessionScanner.AgentSessionScanner, scanner), - Layer.mock(ProjectService.ProjectService)({ - getById: () => - Effect.succeed( - Option.some({ id: projectId, workspaceRoot: "/workspace/project" } as never), + Layer.succeed(SqlClient.SqlClient, importSql), + Layer.succeed(ProjectionStore.ProjectionStoreV2, projections), + Layer.succeed(EventStore.EventStoreV2, { + ...events, + publishCommitted: (batch) => + events.publishCommitted(batch).pipe( + Effect.tap(() => + Effect.sync(() => { + published.push(...batch); + }), + ), ), }), - Layer.mock(Orchestrator.OrchestratorV2)({ - getThreadRecords: () => - imported - ? Effect.succeed({ - thread: { id: threadId, projectId, historyOrigin: "v1_import" }, - } as never) - : Effect.fail(new Orchestrator.OrchestratorProjectionError({ threadId })), - }), - Layer.mock(EventSink.EventSinkV2)({ - write: (input) => - Effect.sync(() => { - writes.push(input.events); - imported = true; - return []; - }), - }), - Layer.mock(ProviderSessionRuntime.ProviderSessionRuntimeRepository)({ - list: () => Effect.succeed([]), - upsert: (input) => Effect.sync(() => void upserts.push(input)), - recordImportedTranscript: (input) => Effect.sync(() => void recorded.push(input)), - }), - IdAllocator.layer, ), ), ); + const context = yield* Layer.build( + AgentSessionImporter.layer.pipe( + Layer.provide( + Layer.mergeAll( + sinkLayer, + Layer.succeed(IdAllocator.IdAllocatorV2, yield* IdAllocator.IdAllocatorV2), + Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository, + ), + Layer.mock(AgentSessionScanner.AgentSessionScanner)({ + recentThreads: () => Stream.fromIterable(candidates), + }), + Layer.mock(ProjectService.ProjectService)({ + getById: () => + Effect.succeed( + Option.some({ + id: projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: timestamp, + updatedAt: timestamp, + deletedAt: null, + }), + ), + }), + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadRecords: (threadId, fields) => + projections + .getThreadRecords(threadId, fields) + .pipe( + Effect.mapError( + (cause) => new Orchestrator.OrchestratorProjectionError({ threadId, cause }), + ), + ), + }), + ), + ), + ), + ); + const importer = Context.get(context, AgentSessionImporter.AgentSessionImporter); + return { run: importer.importRecentAgentThreads({ projectId }), published }; +}); - return Effect.gen(function* () { - const importer = yield* AgentSessionImporter.AgentSessionImporter; - expect(yield* importer.importRecentAgentThreads({ projectId })).toEqual({ - importedCount: 1, - skippedCount: 0, - }); - expect(yield* importer.importRecentAgentThreads({ projectId })).toEqual({ - importedCount: 1, - skippedCount: 0, - }); +it.effect( + "ignores owned sessions, including archived and deleted threads, but imports other instances and unowned sessions", + () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const events = yield* EventStore.EventStoreV2; + const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const active = candidate(CODEX_MODEL_SELECTION, "active"); + const archived = candidate( + CLAUDE_MODEL_SELECTION, + "020e7f00-dce6-406b-8ec6-ed90498945d6", + "claudeAgent", + ); + const deleted = candidate(CODEX_MODEL_SELECTION, "deleted"); + const owners = [ + yield* seedOwner(active), + yield* seedOwner(archived, "archived"), + yield* seedOwner(deleted, "deleted"), + ]; + const before = yield* Effect.forEach(owners, (id) => + projections.getThreadRecords(id, ["providerThreads"]), + ); + const otherInstance = candidate( + { ...CLAUDE_MODEL_SELECTION, instanceId: ProviderInstanceId.make("claude-other") }, + archived.source.providerSessionId, + "claudeAgent", + ); + const unowned = candidate(CODEX_MODEL_SELECTION, "unowned"); + const importer = yield* makeImporter([active, archived, deleted, otherInstance, unowned]); - expect(writes).toHaveLength(1); - expect(writes[0]?.map((event) => event.type)).toEqual([ - "thread.created", - "message.updated", - "turn-item.updated", - "message.updated", - "turn-item.updated", - "provider-thread.updated", - ]); - const created = writes[0]?.find((event) => event.type === "thread.created"); - const providerThread = writes[0]?.find((event) => event.type === "provider-thread.updated"); - expect(created?.payload).toMatchObject({ - id: threadId, - activeProviderThreadId: providerThread?.payload.id, - historyOrigin: "v1_import", - }); - expect(providerThread?.payload).toMatchObject({ - appThreadId: threadId, - nativeThreadRef: { - driver: "codex", - nativeId: providerSessionId, - strength: "strong", - }, - }); + expect(yield* importer.run).toEqual({ importedCount: 2, skippedCount: 0 }); + for (const source of [active, archived, deleted]) { + const threadId = importId(source); + expect(yield* Effect.option(projections.getThread(threadId))).toEqual(Option.none()); + expect(yield* events.read({ threadId }).pipe(Stream.runCollect)).toEqual([]); + expect(yield* runtimes.getByThreadId({ threadId })).toEqual(Option.none()); + } + expect( + yield* Effect.forEach(owners, (id) => + projections.getThreadRecords(id, ["providerThreads"]), + ), + ).toEqual(before); + expect( + new Set( + importer.published.flatMap((stored) => + "event" in stored ? [stored.event.threadId] : [], + ), + ), + ).toEqual(new Set([importId(otherInstance), importId(unowned)])); + for (const source of [otherInstance, unowned]) { + const threadId = importId(source); + const records = yield* projections.getThreadRecords(threadId, [ + "messages", + "providerThreads", + ]); + expect(records.messages.map((message) => message.text)).toEqual(["Fix it", "Fixed"]); + expect(records.thread.historyOrigin).toBe("v1_import"); + expect(records.thread.activeProviderThreadId).toBe(records.providerThreads[0]?.id); + expect(records.providerThreads[0]?.nativeThreadRef?.nativeId).toBe( + source.source.providerSessionId, + ); + expect(Option.getOrThrow(yield* runtimes.getByThreadId({ threadId }))).toMatchObject({ + providerInstanceId: source.source.providerInstanceId, + resumeCursor: + source.source.provider === "codex" + ? { threadId: source.source.providerSessionId } + : { threadId, resume: source.source.providerSessionId }, + runtimePayload: { importedTranscripts: [source.source] }, + }); + } + }).pipe(Effect.scoped, Effect.provide(persistence)), +); + +it.effect("rechecks ownership after the import reaches the commit transaction", () => + Effect.gen(function* () { + const enteringTransaction = yield* Deferred.make(); + const continueTransaction = yield* Deferred.make(); + const source = candidate(CODEX_MODEL_SELECTION, "claimed-during-import"); + const importer = yield* makeImporter( + [source], + Deferred.succeed(enteringTransaction, undefined).pipe( + Effect.andThen(Deferred.await(continueTransaction)), + ), + ); + const pending = yield* Effect.forkScoped(importer.run); + yield* Deferred.await(enteringTransaction); + // Commit ownership after all pre-transaction reads, before the import acquires SQLite. + const ownerId = yield* seedOwner(source); + yield* Deferred.succeed(continueTransaction, undefined); + expect(yield* Fiber.join(pending)).toEqual({ importedCount: 0, skippedCount: 0 }); + expect(yield* importer.run).toEqual({ importedCount: 0, skippedCount: 0 }); + const projections = yield* ProjectionStore.ProjectionStoreV2; + const events = yield* EventStore.EventStoreV2; + const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + expect(yield* Effect.option(projections.getThread(importId(source)))).toEqual(Option.none()); + expect(yield* events.read({ threadId: importId(source) }).pipe(Stream.runCollect)).toEqual([]); + expect(yield* runtimes.list()).toEqual([]); + expect(importer.published).toEqual([]); expect( - writes[0] - ?.filter((event) => event.type === "message.updated") - .map((event) => event.payload.text), - ).toEqual(["Fix it", "Fixed"]); - expect(upserts).toEqual([ - expect.objectContaining({ - threadId, - providerInstanceId, - resumeCursor: { threadId: providerSessionId }, - }), - ]); - expect(recorded).toHaveLength(2); - }).pipe(Effect.provide(layerTest)); -}); + (yield* projections.getThreadRecords(ownerId, ["providerThreads"])).providerThreads[0] + ?.appThreadId, + ).toBe(ownerId); + }).pipe(Effect.scoped, Effect.provide(persistence)), +); + +it.effect( + "retries failed commits and runtime persistence without replaying committed history", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const events = yield* EventStore.EventStoreV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const source = candidate(CODEX_MODEL_SELECTION, "retry"); + const threadId = importId(source); + const importer = yield* makeImporter([source]); + yield* sql`CREATE TEMP TRIGGER fail_import BEFORE INSERT ON orchestration_events WHEN NEW.event_type = 'message.updated' BEGIN SELECT RAISE(ABORT, 'test publication failure'); END`; + expect(yield* importer.run).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(yield* events.read({ threadId }).pipe(Stream.runCollect)).toEqual([]); + expect(yield* Effect.option(projections.getThread(threadId))).toEqual(Option.none()); + expect(yield* runtimes.list()).toEqual([]); + expect(importer.published).toEqual([]); + yield* sql`DROP TRIGGER fail_import`; + + yield* sql`CREATE TEMP TRIGGER fail_runtime BEFORE INSERT ON provider_session_runtime BEGIN SELECT RAISE(ABORT, 'test runtime failure'); END`; + expect(yield* importer.run).toEqual({ importedCount: 0, skippedCount: 1 }); + const committed = yield* events.read({ threadId }).pipe(Stream.runCollect); + expect(committed).toHaveLength(6); + expect(importer.published).toHaveLength(6); + expect(yield* runtimes.list()).toEqual([]); + yield* sql`DROP TRIGGER fail_runtime`; + + expect(yield* importer.run).toEqual({ importedCount: 1, skippedCount: 0 }); + expect(yield* importer.run).toEqual({ importedCount: 1, skippedCount: 0 }); + expect(yield* events.read({ threadId }).pipe(Stream.runCollect)).toEqual(committed); + expect(importer.published).toHaveLength(6); + expect( + (yield* projections.getThreadRecords(threadId, ["messages"])).messages.map( + (message) => message.text, + ), + ).toEqual(["Fix it", "Fixed"]); + expect(Option.getOrThrow(yield* runtimes.getByThreadId({ threadId }))).toMatchObject({ + providerInstanceId: source.source.providerInstanceId, + resumeCursor: { threadId: source.source.providerSessionId }, + runtimePayload: { importedTranscripts: [source.source] }, + }); + }).pipe(Effect.scoped, Effect.provide(persistence)), +); diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 2c8d6a49d4c7..11f168ff1d7b 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -249,6 +249,24 @@ const make = Effect.gen(function* () { providerSessionId: thread.providerSessionId, }); } + const driver = ProviderDriverKind.make(thread.source); + const persistRuntime = runtimes.upsert( + { + threadId, + providerName: driver, + providerInstanceId: thread.providerInstanceId, + adapterKey: driver, + runtimeMode: DEFAULT_RUNTIME_MODE, + status: "stopped", + lastSeenAt: thread.updatedAt, + resumeCursor: + thread.source === "codex" + ? { threadId: thread.providerSessionId } + : { threadId, resume: thread.providerSessionId }, + runtimePayload: { cwd: project.workspaceRoot }, + }, + { onConflict: "ignore" }, + ); const existing = yield* Effect.option(orchestrator.getThreadRecords(threadId, [])); if (Option.isSome(existing)) { if (existing.value.thread.projectId !== input.projectId) { @@ -261,14 +279,15 @@ const make = Effect.gen(function* () { if (existing.value.thread.historyOrigin !== "v1_import") { return yield* new AgentSessionThreadModifiedError({ threadId }); } + yield* persistRuntime; yield* runtimes.recordImportedTranscript({ threadId, source }); return true; } - const driver = ProviderDriverKind.make(thread.source); const model = thread.model ?? DEFAULT_MODEL_BY_PROVIDER[driver] ?? DEFAULT_MODEL; const providerThreadId = idAllocator.derive.providerThread({ driver, + providerInstanceId: thread.providerInstanceId, nativeThreadId: thread.providerSessionId, }); const createdAt = dateTime(thread.createdAt); @@ -332,24 +351,8 @@ const make = Effect.gen(function* () { updatedAt, }; - yield* runtimes.upsert( - { - threadId, - providerName: driver, - providerInstanceId: thread.providerInstanceId, - adapterKey: driver, - runtimeMode: DEFAULT_RUNTIME_MODE, - status: "stopped", - lastSeenAt: thread.updatedAt, - resumeCursor: - thread.source === "codex" - ? { threadId: thread.providerSessionId } - : { threadId, resume: thread.providerSessionId }, - runtimePayload: { cwd: project.workspaceRoot }, - }, - { onConflict: "ignore" }, - ); - yield* eventSink.write({ + const committed = yield* eventSink.write({ + unlessNativeThreadOwned: providerThread, events: [ { id: EventId.make(`${IMPORT_EVENT_PREFIX}:thread:${threadId}:created`), @@ -373,6 +376,8 @@ const make = Effect.gen(function* () { }, ], }); + if (committed.length === 0) return null; + yield* persistRuntime; yield* runtimes.recordImportedTranscript({ threadId, source }); return true; }).pipe( @@ -384,10 +389,10 @@ const make = Effect.gen(function* () { }).pipe(Effect.as(false)), ), ); - if (imported) { + if (imported === true) { importedThreadIds.add(threadId); importedCount += 1; - } else { + } else if (imported === false) { skippedCount += 1; } }), diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 797fdf029258..29a55ec22ba1 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; import { describe, expect, it } from "@effect/vitest"; import { + AgentSessionImportSource, type OrchestrationProjectShell, ProjectId, ProviderDriverKind, @@ -9,6 +10,7 @@ import { type ServerSettings as ContractServerSettings, } from "@t3tools/contracts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -121,6 +123,8 @@ const claudeSessionLine = (cwd: string) => const codexRolloutLine = (cwd: string) => `${JSON.stringify({ timestamp: "2026-01-01T00:00:00.000Z", type: "session_meta", payload: { id: "r1", cwd } })}\n`; +const decodeImportSource = Schema.decodeUnknownSync(AgentSessionImportSource); + const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); function makeRecordLimitTranscript(cwd: string, overflow: boolean): string { @@ -148,6 +152,87 @@ function makeRecordLimitTranscript(cwd: string, overflow: boolean): string { : records; } +const makeFileIdentityFixture = Effect.fn("AgentSessionScanner.test.makeFileIdentityFixture")( + function* (originalInode = 10_000_000_000_000_001n, replacementInode = 10_000_000_000_000_000n) { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-large-id-claude-"); + const codexHomePath = yield* makeTempDir("t3code-large-id-codex-"); + const workspace = yield* makeTempDir("t3code-large-id-workspace-"); + const filePath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-identity.jsonl", + ); + const replacementPath = `${filePath}.replacement`; + const savedPath = `${filePath}.saved`; + for (const [target, sessionId] of [ + [filePath, "original-session"], + [replacementPath, "replaced-session"], + ] as const) { + yield* writeTranscript({ + filePath: target, + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + } + + // Adjacent NTFS IDs round to the same number; birthtimes can also collide. + const birthtime = Option.some(DateTime.toDateUtc(DateTime.makeUnsafe(nowMs))); + const ids = new Map(); + const effectIdentity = (target: string, info: FileSystem.File.Info) => { + if (target !== filePath) return info; + const realInode = Option.getOrThrow(info.ino); + const inode = ids.get(realInode) ?? (ids.size === 0 ? originalInode : replacementInode); + ids.set(realInode, inode); + return { ...info, ino: Option.some(inode), birthtime }; + }; + let opens = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (target) => + fileSystem.stat(target).pipe(Effect.map((info) => effectIdentity(target, info))), + open: (target, options) => { + if (target === filePath) opens += 1; + return fileSystem.open(target, options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat.pipe(Effect.map((info) => effectIdentity(target, info))), + readAlloc: (size: number) => file.readAlloc(size), + })), + ); + }, + }); + const scanner = yield* AgentSessionScanner.AgentSessionScanner.pipe( + Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath })), + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + return { + scanner, + workspace, + opens: () => opens, + replace: Effect.gen(function* () { + yield* fileSystem.rename(filePath, savedPath); + yield* fileSystem.rename(replacementPath, filePath); + }).pipe(Effect.orDie), + }; + }, +); + it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { describe("scan", () => { it.effect("reads Claude project cwds from transcripts, newest first", () => @@ -199,131 +284,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("groups Codex rollouts by cwd across date directories", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); - - const rollout = (year: string, month: string, day: string, name: string) => - path.join(codexHomePath, "sessions", year, month, day, name); - - yield* writeTranscript({ - filePath: rollout("2026", "01", "05", "rollout-2026-01-05T10-00-00-aaa.jsonl"), - contents: codexRolloutLine(workspace), - mtimeMs: Date.parse("2026-01-05T10:00:00.000Z"), - }); - yield* writeTranscript({ - filePath: rollout("2026", "02", "09", "rollout-2026-02-09T10-00-00-bbb.jsonl"), - contents: codexRolloutLine(workspace), - mtimeMs: Date.parse("2026-02-09T10:00:00.000Z"), - }); - yield* writeTranscript({ - filePath: rollout("2026", "02", "09", "rollout-2026-02-09T11-00-00-ccc.jsonl"), - contents: codexRolloutLine(otherWorkspace), - mtimeMs: Date.parse("2026-02-09T11:00:00.000Z"), - }); - - const result = yield* runScan({ claudeHomePath, codexHomePath }); - - expect(result.candidates).toEqual([ - { - path: otherWorkspace, - title: path.basename(otherWorkspace), - sources: ["codex"], - threadCount: 1, - lastActiveAt: "2026-02-09T11:00:00.000Z", - alreadyImported: false, - git: null, - }, - { - path: workspace, - title: path.basename(workspace), - sources: ["codex"], - threadCount: 2, - lastActiveAt: "2026-02-09T10:00:00.000Z", - alreadyImported: false, - git: null, - }, - ]); - }), - ); - - it.effect.each(["claudeAgent", "codex"] as const)( - "does not open a non-file %s transcript", - (source) => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const transcriptPath = - source === "claudeAgent" - ? path.join(claudeHomePath, "projects", "-slug", "session.jsonl") - : path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-session.jsonl"); - yield* fileSystem.makeDirectory(transcriptPath, { recursive: true }); - - let transcriptOpenCount = 0; - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - open: (filePath, options) => { - if (filePath === transcriptPath) transcriptOpenCount += 1; - return fileSystem.open(filePath, options); - }, - }); - - const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( - Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), - ); - - expect(result.candidates).toEqual([]); - expect(transcriptOpenCount).toBe(0); - }), - ); - - it.effect.each(["claudeAgent", "codex"] as const)( - "stops %s directory reads at the discovery operation budget", - (source) => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const discoveryRoot = - source === "claudeAgent" - ? path.join(claudeHomePath, "projects") - : path.join(codexHomePath, "sessions"); - const emptyDirectories = Array.from( - { length: 20_001 }, - (_, index) => `empty-${index.toString().padStart(5, "0")}`, - ); - let directoryReadCount = 0; - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - readDirectory: (directory, options) => { - if (directory === discoveryRoot) { - directoryReadCount += 1; - return Effect.succeed(emptyDirectories); - } - if (path.dirname(directory) === discoveryRoot) { - directoryReadCount += 1; - return Effect.succeed([]); - } - return fileSystem.readDirectory(directory, options); - }, - }); - - const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( - Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), - ); - - expect(result.candidates).toEqual([]); - expect(directoryReadCount).toBe(20_000); - }), - ); - it.effect("merges the same cwd seen by both agents and flags imported projects", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -402,38 +362,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("matches a persisted project alias to a transcript realpath", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const linkParent = yield* makeTempDir("t3code-scanner-links-"); - const workspaceAlias = path.join(linkParent, "workspace-alias"); - yield* fileSystem.symlink(workspace, workspaceAlias); - - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), - contents: claudeSessionLine(workspace), - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), - }); - - const result = yield* runScan({ - claudeHomePath, - codexHomePath, - importedWorkspaceRoots: [workspaceAlias], - }); - - expect(result.candidates[0]).toMatchObject({ - path: workspaceAlias, - projectId: ProjectId.make("project-1"), - alreadyImported: true, - git: null, - }); - }), - ); - it.effect("merges case aliases and preserves the persisted project path", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -680,34 +608,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("ignores invalid provider instances while scanning the remaining providers", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-actual", "session.jsonl"), - contents: claudeSessionLine(workspace), - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), - }); - - const result = yield* runScan({ - claudeHomePath, - codexHomePath, - providerInstances: { - [ProviderInstanceId.make("codex")]: { - driver: ProviderDriverKind.make("codex"), - config: { homePath: 123 }, - }, - }, - }); - - expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); - }), - ); - it.effect("does not scan provider instances disabled by the envelope or config", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -749,25 +649,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("ignores relative working directories from malformed transcripts", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-relative", "session.jsonl"), - contents: claudeSessionLine(path.relative(path.resolve(), workspace)), - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), - }); - - const result = yield* runScan({ claudeHomePath, codexHomePath }); - - expect(result.candidates).toEqual([]); - }), - ); - it.effect("drops candidates whose directory no longer exists", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -834,48 +715,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("excludes Codex scratch directories and Downloads", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const home = NodeOS.homedir(); - const runId = path.basename(yield* makeTempDir("t3code-scanner-test-")); - const scratchRoot = path.join(home, "Documents", "Codex", runId); - const scratch = path.join(scratchRoot, "2026-09-01", "some-conversation"); - const downloads = path.join(home, "Downloads", runId); - const keep = yield* makeTempDir("t3code-workspace-keep-"); - yield* fileSystem.makeDirectory(scratch, { recursive: true }); - yield* fileSystem.makeDirectory(downloads, { recursive: true }); - yield* Effect.addFinalizer(() => - Effect.all([ - fileSystem.remove(scratchRoot, { recursive: true }).pipe(Effect.ignore), - fileSystem.remove(downloads, { recursive: true }).pipe(Effect.ignore), - ]), - ); - - for (const [index, cwd] of [scratch, downloads, keep].entries()) { - yield* writeTranscript({ - filePath: path.join( - codexHomePath, - "sessions", - "2026", - "09", - "01", - `rollout-${index}.jsonl`, - ), - contents: codexRolloutLine(cwd), - mtimeMs: Date.parse("2026-09-01T00:00:00.000Z"), - }); - } - - const result = yield* runScan({ claudeHomePath, codexHomePath }); - - expect(result.candidates.map((candidate) => candidate.path)).toEqual([keep]); - }), - ); - it.effect("skips linked git worktrees and reports the origin of real checkouts", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -893,9 +732,12 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { path.join(repo, ".git", "config"), '[core]\n\tbare = false\n[remote "origin"]\n\turl = git@github.com:pingdotgg/t3code.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n', ); + const worktreeGitDir = path.join(repo, ".git", "worktrees", "wt"); + yield* fileSystem.makeDirectory(worktreeGitDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(worktreeGitDir, "commondir"), "../..\n"); yield* fileSystem.writeFileString( path.join(worktree, ".git"), - `gitdir: ${path.join(repo, ".git", "worktrees", "wt")}\n`, + `gitdir: ${worktreeGitDir}\n`, ); yield* fileSystem.makeDirectory(path.join(noRemote, ".git")); yield* fileSystem.writeFileString(path.join(noRemote, ".git", "config"), "[core]\n"); @@ -937,6 +779,75 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect( + "offers bare-repository worktrees separately and imports only their own sessions", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const bare = yield* makeTempDir("t3code-workspace-bare-"); + const bareGitDir = path.join(bare, ".git"); + yield* fileSystem.makeDirectory(bareGitDir); + yield* fileSystem.writeFileString( + path.join(bareGitDir, "config"), + '[core] bare = "true" ; bare repository\n[remote "origin"]\nurl = git@github.com:pingdotgg/t3code.git\n', + ); + const dev = path.join(bare, "dev"); + const prod = path.join(bare, "prod"); + const managed = path.join(configBaseDir, "worktrees", "t3code", "sandbox"); + for (const [index, cwd] of [bare, dev, prod, managed].entries()) { + if (cwd !== bare) { + const gitDir = path.join(bareGitDir, "worktrees", path.basename(cwd)); + yield* fileSystem.makeDirectory(gitDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(gitDir, "commondir"), "../..\n"); + yield* fileSystem.makeDirectory(cwd, { recursive: true }); + yield* fileSystem.writeFileString(path.join(cwd, ".git"), `gitdir: ${gitDir}\n`); + } + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${index}.jsonl`, + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: `session-${index}`, cwd }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: `Work in ${cwd}` }, + }), + ].join("\n"), + mtimeMs: nowMs - index * 1_000, + }); + } + + const input = { claudeHomePath, codexHomePath, configBaseDir }; + const result = yield* runScan(input); + const git = { remoteKey: "github.com/pingdotgg/t3code", repository: "pingdotgg/t3code" }; + expect( + result.candidates.map((candidate) => ({ path: candidate.path, git: candidate.git })), + ).toEqual([ + { path: bare, git }, + { path: dev, git }, + { path: prod, git }, + ]); + const threads = yield* runRecentThreads({ ...input, workspaceRoot: dev }); + expect(threads.map((thread) => thread.providerSessionId)).toEqual(["session-1"]); + const managedThreads = yield* runRecentThreads({ ...input, workspaceRoot: managed }); + expect(managedThreads).toEqual([]); + }), + ); + it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -1028,356 +939,48 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("finds session metadata after a first record larger than one chunk", () => + it.effect("skips malformed transcripts without failing the scan", () => Effect.gen(function* () { const path = yield* Path.Path; const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); const codexHomePath = yield* makeTempDir("t3code-codex-home-"); const workspace = yield* makeTempDir("t3code-workspace-"); - const history = `{"type":"file-history-snapshot","data":"${"x".repeat(32 * 1024)}"}\n`; yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-large", "session.jsonl"), - contents: `${history}${claudeSessionLine(workspace)}`, - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + filePath: path.join(claudeHomePath, "projects", "-broken", "a.jsonl"), + contents: "not json at all\n", + mtimeMs: Date.parse("2026-05-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-no-cwd", "a.jsonl"), + contents: `{"type":"summary"}\n`, + mtimeMs: Date.parse("2026-05-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-good", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), }); const result = yield* runScan({ claudeHomePath, codexHomePath }); - expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); - }), - ); - - it.effect.each([64, 65])("shares metadata bytes across homes for %s one-MiB files", (count) => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-metadata-home-"); - const secondHome = yield* makeTempDir("t3code-metadata-second-"); - const codexHomePath = yield* makeTempDir("t3code-metadata-codex-"); - const firstWorkspace = yield* makeTempDir("t3code-metadata-first-project-"); - const secondWorkspace = yield* makeTempDir("t3code-metadata-second-project-"); - const directories = [ - path.join(claudeHomePath, "projects", "p"), - path.join(secondHome, "projects", "p"), - ]; - const templates = directories.map((directory) => path.join(directory, "template.jsonl")); - for (const [index, workspace] of [firstWorkspace, secondWorkspace].entries()) { - const record = encodeTranscriptRecord({ cwd: workspace }); - yield* writeTranscript({ - filePath: templates[index]!, - contents: - " ".repeat(1024 * 1024 - new TextEncoder().encode(record).byteLength) + record, - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") - index * 1_000, - }); - } - const resolveFile = (filePath: string) => { - const index = directories.indexOf(path.dirname(filePath)); - return index === -1 ? filePath : templates[index]!; - }; - let reservedBytes = 0; - let opens = 0; - const requests: number[] = []; - const observedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - readDirectory: (directory, options) => { - const index = directories.indexOf(directory); - return index === -1 - ? fileSystem.readDirectory(directory, options) - : Effect.succeed( - Array.from( - { length: index === 0 ? 32 : count - 32 }, - (_, item) => `session-${item}.jsonl`, - ), - ); - }, - stat: (filePath) => fileSystem.stat(resolveFile(filePath)), - open: (filePath, options) => { - if (!directories.includes(path.dirname(filePath))) - return fileSystem.open(filePath, options); - opens += 1; - return fileSystem.open(resolveFile(filePath), options).pipe( - Effect.map((file) => ({ - ...file, - stat: file.stat, - readAlloc: (size: number) => { - reservedBytes += Number(size); - requests.push(Number(size)); - return file.readAlloc(size); - }, - })), - ); - }, - }); - const result = yield* runScan({ - claudeHomePath, - codexHomePath, - providerInstances: { - [ProviderInstanceId.make("claude-work")]: { - driver: ProviderDriverKind.make("claudeAgent"), - config: { homePath: secondHome }, - }, + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + sources: ["claudeAgent"], + threadCount: 1, + lastActiveAt: "2026-05-03T00:00:00.000Z", + alreadyImported: false, + git: null, }, - }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); - expect(result.candidates.map((candidate) => candidate.path)).toEqual([ - firstWorkspace, - secondWorkspace, ]); - expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([32, 32]); - expect(result.truncated).toBe(count === 65 ? true : undefined); - expect(opens).toBe(64); - expect(reservedBytes).toBe(64 * 1024 * 1024); - expect(requests[0]).toBe(8 * 1024); - expect(Math.max(...requests)).toBe(8 * 1024); }), ); + }); - it.effect.each([50, 51])("bounds metadata open/read calls for %s short-read files", (count) => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-short-metadata-home-"); - const codexHomePath = yield* makeTempDir("t3code-short-metadata-codex-"); - const workspace = yield* makeTempDir("t3code-short-metadata-project-"); - const directory = path.join(claudeHomePath, "projects", "p"); - const template = path.join(directory, "template.jsonl"); - const record = encodeTranscriptRecord({ cwd: workspace }); - const contents = " ".repeat(399 - new TextEncoder().encode(record).byteLength) + record; - const bytes = new TextEncoder().encode(contents); - yield* writeTranscript({ - filePath: template, - contents, - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), - }); - let operations = 0; - const observedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - readDirectory: (target, options) => - target === directory - ? Effect.succeed( - Array.from({ length: count }, (_, index) => `session-${index}.jsonl`), - ) - : fileSystem.readDirectory(target, options), - stat: (filePath) => - fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), - open: (filePath, options) => { - if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); - operations += 1; - let offset = 0; - return fileSystem.open(template, options).pipe( - Effect.map((file) => ({ - ...file, - stat: file.stat, - readAlloc: () => - Effect.sync(() => { - operations += 1; - if (offset === bytes.length) return Option.none(); - return Option.some(bytes.subarray(offset, ++offset)); - }), - })), - ); - }, - }); - const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( - Effect.provideService(FileSystem.FileSystem, observedFileSystem), - ); - expect(operations).toBe(20_000); - expect(result.candidates[0]?.threadCount).toBe(50); - expect(result.truncated).toBe(count === 51 ? true : undefined); - }), - ); - - it.effect("bounds malformed metadata records without excluding another account", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-record-metadata-home-"); - const secondHome = yield* makeTempDir("t3code-record-metadata-second-"); - const codexHomePath = yield* makeTempDir("t3code-record-metadata-codex-"); - const workspace = yield* makeTempDir("t3code-record-metadata-project-"); - const directory = path.join(claudeHomePath, "projects", "p"); - const template = path.join(directory, "template.jsonl"); - yield* writeTranscript({ - filePath: template, - contents: "x\n".repeat(1_001), - mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), - }); - yield* writeTranscript({ - filePath: path.join(secondHome, "projects", "p", "session.jsonl"), - contents: encodeTranscriptRecord({ cwd: workspace }), - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), - }); - let malformedOpens = 0; - const observedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - readDirectory: (target, options) => - target === directory - ? Effect.succeed(Array.from({ length: 102 }, (_, index) => `session-${index}.jsonl`)) - : fileSystem.readDirectory(target, options), - stat: (filePath) => - fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), - open: (filePath, options) => { - if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); - malformedOpens += 1; - return fileSystem.open(template, options); - }, - }); - const result = yield* runScan({ - claudeHomePath, - codexHomePath, - providerInstances: { - [ProviderInstanceId.make("claude-work")]: { - driver: ProviderDriverKind.make("claudeAgent"), - config: { homePath: secondHome }, - }, - }, - }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); - expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); - expect(malformedOpens).toBe(100); - expect(result.truncated).toBe(true); - }), - ); - - it.effect.each([19_999, 20_000])( - "reports unfinished directory work for %s project directories", - (count) => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-directory-budget-home-"); - const codexHomePath = yield* makeTempDir("t3code-directory-budget-codex-"); - const projectsDir = path.join(claudeHomePath, "projects"); - let reads = 0; - const observedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - readDirectory: (directory, options) => { - if (directory === projectsDir) { - reads += 1; - return Effect.succeed( - Array.from({ length: count }, (_, index) => `project-${index}`), - ); - } - if (path.dirname(directory) === projectsDir) { - reads += 1; - return Effect.succeed([]); - } - return fileSystem.readDirectory(directory, options); - }, - }); - const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( - Effect.provideService(FileSystem.FileSystem, observedFileSystem), - ); - expect(reads).toBe(20_000); - expect(result.candidates).toEqual([]); - expect(result.truncated).toBe(count === 20_000 ? true : undefined); - }), - ); - - it.effect("skips malformed transcripts without failing the scan", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-broken", "a.jsonl"), - contents: "not json at all\n", - mtimeMs: Date.parse("2026-05-01T00:00:00.000Z"), - }); - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-no-cwd", "a.jsonl"), - contents: `{"type":"summary"}\n`, - mtimeMs: Date.parse("2026-05-02T00:00:00.000Z"), - }); - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-good", "a.jsonl"), - contents: claudeSessionLine(workspace), - mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), - }); - - const result = yield* runScan({ claudeHomePath, codexHomePath }); - - expect(result.candidates).toEqual([ - { - path: workspace, - title: path.basename(workspace), - sources: ["claudeAgent"], - threadCount: 1, - lastActiveAt: "2026-05-03T00:00:00.000Z", - alreadyImported: false, - git: null, - }, - ]); - }), - ); - - it.effect("returns an empty result when neither home directory exists", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const root = yield* makeTempDir("t3code-missing-homes-"); - - const result = yield* runScan({ - claudeHomePath: path.join(root, "no-claude"), - codexHomePath: path.join(root, "no-codex"), - }); - - expect(result.candidates).toEqual([]); - expect(result.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }), - ); - }); - - describe("recentThreads", () => { - it.effect.each([false, true])( - "counts terminal newlines correctly with record overflow=%s", - (overflow) => - Effect.gen(function* () { - const path = yield* Path.Path; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-record-limit-claude-"); - const codexHomePath = yield* makeTempDir("t3code-record-limit-codex-"); - const workspace = yield* makeTempDir("t3code-record-limit-project-"); - const directory = path.join(codexHomePath, "sessions", "2026", "08", "24"); - yield* writeTranscript({ - filePath: path.join(directory, "rollout-records.jsonl"), - contents: makeRecordLimitTranscript(workspace, overflow), - mtimeMs: nowMs, - }); - yield* writeTranscript({ - filePath: path.join(directory, "rollout-older.jsonl"), - contents: [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "older-session", cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Older prompt" }, - }), - ].join("\n"), - mtimeMs: nowMs - 1_000, - }); - const outcomes = yield* runRecentThreadOutcomes({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }); - expect(outcomes.map((outcome) => outcome._tag)).toEqual( - overflow ? ["Skipped", "Importable"] : ["Importable", "Skipped"], - ); - expect( - outcomes.flatMap((outcome) => - outcome._tag === "Importable" - ? outcome.thread.messages.map((message) => message.text) - : [], - ), - ).toEqual([overflow ? "Older prompt" : "First prompt"]); - }), - ); - - it.effect("imports recent Claude and Codex sessions for the selected project only", () => + describe("recentThreads", () => { + it.effect("imports recent Claude and Codex sessions for the selected project only", () => Effect.gen(function* () { const path = yield* Path.Path; const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); @@ -1465,54 +1068,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("imports history recorded with a case alias", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const workspaceAlias = path.join( - path.dirname(workspace), - path.basename(workspace).toUpperCase(), - ); - - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-alias", "case-session.jsonl"), - contents: [ - encodeTranscriptRecord({ - type: "user", - cwd: workspaceAlias, - sessionId: "case-session", - timestamp: "2026-08-24T10:00:00.000Z", - message: { role: "user", content: "Import case alias history" }, - }), - encodeTranscriptRecord({ - type: "assistant", - sessionId: "case-session", - timestamp: "2026-08-24T10:01:00.000Z", - message: { role: "assistant", content: "Imported" }, - }), - ].join("\n"), - mtimeMs: nowMs, - }); - - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath), - }); - const threads = yield* runRecentThreads({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); - - expect(threads.map((thread) => thread.providerSessionId)).toEqual(["case-session"]); - }), - ); - it.effect("keeps the provider instance that owns a custom session home", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -1554,6 +1109,67 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("excludes Codex subagents from discovery and the recent-thread budget", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const subagentSources = [ + { subagent: { thread_spawn: { parent_thread_id: "session-100", depth: 1 } } }, + { subagent: "review" }, + { subagent: { other: "guardian" } }, + { subAgent: "compact" }, + ]; + // Newer subagents would exhaust the 100-transcript import budget before any parent. + const sources = [ + ...Array.from({ length: 100 }, (_, index) => subagentSources[index % 4]), + "vscode", + undefined, + null, + { custom: "launcher" }, + ]; + for (const [index, source] of sources.entries()) { + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${index}.jsonl`, + ), + contents: [ + { type: "session_meta", payload: { id: `session-${index}`, cwd: workspace, source } }, + // A later cwd must not make an excluded rollout eligible again. + { type: "turn_context", payload: { cwd: workspace } }, + { + type: "event_msg", + payload: { type: "user_message", message: "Import this prompt" }, + }, + ] + .map((record) => encodeTranscriptRecord(record)) + .join("\n"), + mtimeMs: nowMs - index * 60_000, + }); + } + + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect.soft(scan.candidates.map((candidate) => candidate.threadCount)).toEqual([4]); + const outcomes = yield* Stream.runCollect(scanner.recentThreads(workspace)); + expect( + outcomes.map((outcome) => + outcome._tag === "Importable" ? outcome.thread.providerSessionId : outcome._tag, + ), + ).toEqual(["session-100", "session-101", "session-102", "session-103"]); + }).pipe(Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath }))); + }), + ); + it.effect("suppresses duplicate session copies without reporting a skipped import", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -1598,109 +1214,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("streams large transcripts across providers without hiding projects", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-budget-claude-"); - const codexHomePath = yield* makeTempDir("t3code-budget-codex-"); - const workspace = yield* makeTempDir("t3code-budget-workspace-"); - const transcriptPaths = new Set(); - for (const [index, source] of [ - "codex", - "claudeAgent", - "codex", - "claudeAgent", - "codex", - ].entries()) { - const sessionId = `budget-session-${index}`; - const filePath = - source === "codex" - ? path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - `rollout-${sessionId}.jsonl`, - ) - : path.join(claudeHomePath, "projects", "selected", `${sessionId}.jsonl`); - const contents = - source === "codex" - ? [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: sessionId, cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Imported prompt" }, - }), - ].join("\n") - : encodeTranscriptRecord({ - type: "user", - cwd: workspace, - sessionId, - message: { content: "Imported prompt" }, - }); - transcriptPaths.add(filePath); - yield* writeTranscript({ - filePath, - contents: `${contents}\n`.padEnd(16 * 1024 * 1024, " "), - mtimeMs: nowMs - index * 1_000, - }); - } - - const opens = new Map(); - let fullReadBytes = 0; - const trackedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - open: (filePath, options) => { - const count = (opens.get(filePath) ?? 0) + 1; - opens.set(filePath, count); - return fileSystem.open(filePath, options).pipe( - Effect.map((file) => - !transcriptPaths.has(filePath) || count === 1 - ? file - : { - ...file, - stat: file.stat, - readAlloc: (size: number) => - file.readAlloc(size).pipe( - Effect.tap((chunk) => - Effect.sync(() => { - if (chunk._tag === "Some") fullReadBytes += chunk.value.byteLength; - }), - ), - ), - }, - ), - ); - }, - }); - const outcomes = yield* Effect.gen(function* () { - const scanner = yield* AgentSessionScanner.AgentSessionScanner; - const scan = yield* scanner.scan; - expect(scan.candidates[0]?.threadCount).toBe(5); - return yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); - }).pipe( - Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath })), - Effect.provideService(FileSystem.FileSystem, trackedFileSystem), - ); - - expect(outcomes.map((outcome) => outcome._tag)).toEqual([ - "Importable", - "Importable", - "Importable", - "Importable", - "Importable", - ]); - expect(fullReadBytes).toBe(80 * 1024 * 1024); - }), - ); - it.effect("skips excessive records without blocking an older valid transcript", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -1844,536 +1357,127 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { it.effect("checks file identity and provider before skipping completed history", () => Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-completed-claude-"); - const codexHomePath = yield* makeTempDir("t3code-completed-codex-"); - const workspace = yield* makeTempDir("t3code-completed-workspace-"); - const filePath = path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - "rollout-replaced.jsonl", - ); - const contents = (sessionId: string) => - [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: sessionId, cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Imported prompt" }, - }), - ].join("\n"); - yield* writeTranscript({ - filePath, - contents: contents("original-session"), - mtimeMs: nowMs, + const fixture = yield* makeFileIdentityFixture(42n, 43n); + const { scanner, workspace } = fixture; + const initial = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + const imported = initial[0]; + expect(imported?._tag).toBe("Importable"); + if (imported?._tag !== "Importable") return; + const legacy = decodeImportSource({ ...imported.source, inode: 42 }); + const opensBeforeCompleted = fixture.opens(); + const completed = yield* scanner.recentThreads(workspace, [legacy]).pipe(Stream.runCollect); + expect(completed[0]?._tag).toBe("AlreadyImported"); + expect(fixture.opens()).toBe(opensBeforeCompleted); + const wrongProvider = yield* scanner + .recentThreads(workspace, [{ ...imported.source, provider: "claudeAgent" }]) + .pipe(Stream.runCollect); + expect(wrongProvider[0]?._tag).toBe("Importable"); + + yield* fixture.replace; + const replaced = yield* scanner + .recentThreads(workspace, [imported.source]) + .pipe(Stream.runCollect); + expect(replaced[0]).toMatchObject({ + _tag: "Importable", + thread: { providerSessionId: "replaced-session" }, + source: { size: imported.source.size, mtimeMs: imported.source.mtimeMs }, }); + }), + ); - yield* Effect.gen(function* () { - const scanner = yield* AgentSessionScanner.AgentSessionScanner; + it.effect( + "imports same-metadata replacements with exact large file IDs and backfills legacy IDs once", + () => + Effect.gen(function* () { + const fixture = yield* makeFileIdentityFixture(); + const { scanner, workspace } = fixture; const initial = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); const imported = initial[0]; - expect(imported?._tag).toBe("Importable"); - if (imported?._tag !== "Importable") return; - const completed = yield* scanner - .recentThreads(workspace, [imported.source]) - .pipe(Stream.runCollect); - expect(completed[0]?._tag).toBe("AlreadyImported"); - const wrongProvider = yield* scanner - .recentThreads(workspace, [{ ...imported.source, provider: "claudeAgent" }]) - .pipe(Stream.runCollect); - expect(wrongProvider[0]?._tag).toBe("Importable"); - - yield* fileSystem.open(filePath); - yield* fileSystem.remove(filePath); - yield* writeTranscript({ - filePath, - contents: contents("replaced-session"), - mtimeMs: nowMs, - }); - const replaced = yield* scanner - .recentThreads(workspace, [imported.source]) - .pipe(Stream.runCollect); - expect(replaced[0]).toMatchObject({ - _tag: "Importable", - thread: { providerSessionId: "replaced-session" }, - source: { size: imported.source.size, mtimeMs: imported.source.mtimeMs }, - }); - }).pipe(Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath }))); - }), - ); - - it.effect("imports visible history from a transcript with an oversized tool record", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const transcript = `${[ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "large-session", cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Import this large session" }, - }), - ].join("\n")}\n${encodeTranscriptRecord({ type: "tool_result", data: "" }).padEnd( - 16 * 1024 * 1024 + 1, - " ", - )}`; - yield* writeTranscript({ - filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-large.jsonl"), - contents: transcript, - mtimeMs: nowMs, - }); - - const outcomes = yield* runRecentThreadOutcomes({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }); - - expect(outcomes).toMatchObject([ - { - _tag: "Importable", - thread: { - providerSessionId: "large-session", - messages: [{ role: "user", text: "Import this large session" }], - }, - }, - ]); - }), - ); - - it.effect("reports stat, read, and parse failures as skipped", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const missingPath = path.join(codexHomePath, "missing.jsonl"); - const transcriptPaths = { - stat: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-stat.jsonl"), - read: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-read.jsonl"), - parse: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-parse.jsonl"), - }; - const transcriptContents = (sessionId: string) => - [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: sessionId, cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Import this session" }, - }), - ].join("\n"); - - yield* writeTranscript({ - filePath: transcriptPaths.stat, - contents: transcriptContents("stat-session"), - mtimeMs: nowMs, - }); - yield* writeTranscript({ - filePath: transcriptPaths.read, - contents: transcriptContents("read-session"), - mtimeMs: nowMs, - }); - yield* writeTranscript({ - filePath: transcriptPaths.parse, - contents: encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "parse-session", cwd: workspace }, - }), - mtimeMs: nowMs, - }); - - let statCount = 0; - let readOpenCount = 0; - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - stat: (filePath) => { - if (filePath !== transcriptPaths.stat) return fileSystem.stat(filePath); - statCount += 1; - return fileSystem.stat(statCount === 1 ? filePath : missingPath); - }, - open: (filePath, options) => { - if (filePath !== transcriptPaths.read) return fileSystem.open(filePath, options); - readOpenCount += 1; - return fileSystem.open(readOpenCount === 1 ? filePath : missingPath, options); - }, - }); - - const outcomes = yield* runRecentThreadOutcomes({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); - - expect(outcomes).toEqual([{ _tag: "Skipped" }, { _tag: "Skipped" }, { _tag: "Skipped" }]); - }), - ); - - it.effect("does not reopen a transcript that becomes a non-file after discovery", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const nonFilePath = yield* makeTempDir("t3code-non-file-"); - const transcriptPath = path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - "rollout-changed.jsonl", - ); - yield* writeTranscript({ - filePath: transcriptPath, - contents: [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "changed-session", cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Do not import this session" }, - }), - ].join("\n"), - mtimeMs: nowMs, - }); - - let transcriptStatCount = 0; - let transcriptOpenCount = 0; - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - stat: (filePath) => { - if (filePath !== transcriptPath) return fileSystem.stat(filePath); - transcriptStatCount += 1; - return fileSystem.stat(transcriptStatCount === 1 ? transcriptPath : nonFilePath); - }, - open: (filePath, options) => { - if (filePath === transcriptPath) transcriptOpenCount += 1; - return fileSystem.open(filePath, options); - }, - }); - - const outcomes = yield* runRecentThreadOutcomes({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); - - expect(transcriptStatCount).toBe(2); - expect(transcriptOpenCount).toBe(1); - expect(outcomes).toEqual([{ _tag: "Skipped" }]); - }), - ); - - it.effect("does not import a transcript dated after the current time", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - yield* writeTranscript({ - filePath: path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - "rollout-future.jsonl", - ), - contents: [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "future-session", cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Future work" }, - }), - ].join("\n"), - // Node's BigInt stat (which the Effect file system now uses) floors - // sub-millisecond precision, so a one-millisecond offset can round - // back to `nowMs`; use a full second to stay clear of the clock. - mtimeMs: nowMs + 1_000, - }); - - const outcomes = yield* runRecentThreadOutcomes({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }); - - expect(outcomes).toEqual([]); - }), - ); - - it.effect("skips growth during reading without exceeding the reserved bytes", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const transcriptPath = path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - "rollout-growing.jsonl", - ); - const contents = [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "growing-session", cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Do not import a changing file" }, - }), - ].join("\n"); - yield* writeTranscript({ filePath: transcriptPath, contents, mtimeMs: nowMs }); - let transcriptOpenCount = 0; - let fullReadBytes = 0; - let grew = false; - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - open: (filePath, options) => { - if (filePath !== transcriptPath) return fileSystem.open(filePath, options); - transcriptOpenCount += 1; - if (transcriptOpenCount === 1) return fileSystem.open(filePath, options); - return fileSystem.open(filePath, options).pipe( - Effect.map((file) => ({ - ...file, - stat: file.stat, - readAlloc: (size: number) => - file.readAlloc(size).pipe( - Effect.tap((chunk) => - Effect.gen(function* () { - if (chunk._tag === "None") return; - fullReadBytes += chunk.value.byteLength; - if (!grew) { - grew = true; - yield* fileSystem.writeFileString(filePath, `${contents}\nchanged`); - } - }), - ), - ), - })), - ); - }, - }); - - const outcomes = yield* runRecentThreadOutcomes({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); - - expect(transcriptOpenCount).toBe(2); - expect(fullReadBytes).toBe(new TextEncoder().encode(contents).byteLength); - expect(outcomes).toEqual([{ _tag: "Skipped" }]); - }), - ); - - it.effect("skips a transcript that shrinks after its size check", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const transcriptPath = path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - "rollout-shrinking.jsonl", - ); - const shrunkPath = path.join(codexHomePath, "shrunk.jsonl"); - const contents = [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "shrinking-session", cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Do not import a changing file" }, - }), - ].join("\n"); - yield* writeTranscript({ - filePath: transcriptPath, - contents: `${contents}\n${"padding".repeat(100)}`, - mtimeMs: nowMs, - }); - yield* writeTranscript({ filePath: shrunkPath, contents, mtimeMs: nowMs }); - - let transcriptOpenCount = 0; - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - open: (filePath, options) => { - if (filePath !== transcriptPath) return fileSystem.open(filePath, options); - transcriptOpenCount += 1; - return fileSystem.open( - transcriptOpenCount === 1 ? transcriptPath : shrunkPath, - options, - ); - }, - }); - - const outcomes = yield* runRecentThreadOutcomes({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); - - expect(transcriptOpenCount).toBe(2); - expect(outcomes).toEqual([{ _tag: "Skipped" }]); - }), - ); - - it.effect("does not read the second transcript when the consumer takes one thread", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const makeCodexTranscript = (sessionId: string, text: string) => - [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: sessionId, cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: text }, - }), - ].join("\n"); - const olderPath = path.join( - codexHomePath, - "sessions", - "2026", - "08", - "23", - "rollout-older.jsonl", - ); - const newerPath = path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - "rollout-newer.jsonl", - ); - yield* writeTranscript({ - filePath: olderPath, - contents: makeCodexTranscript("older-session", "Older prompt"), - mtimeMs: nowMs - 1_000, - }); - yield* writeTranscript({ - filePath: newerPath, - contents: makeCodexTranscript("newer-session", "Newer prompt"), - mtimeMs: nowMs, - }); - - const openCounts = new Map(); - const contentReads: Array = []; - const trackedPaths = new Set([olderPath, newerPath]); - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - open: (filePath, options) => { - if (trackedPaths.has(filePath)) { - const count = (openCounts.get(filePath) ?? 0) + 1; - openCounts.set(filePath, count); - if (count === 2) contentReads.push(filePath); - } - return fileSystem.open(filePath, options); - }, - }); - - const threads = yield* Effect.gen(function* () { - const scanner = yield* AgentSessionScanner.AgentSessionScanner; - return yield* scanner.recentThreads(workspace).pipe( - Stream.take(1), - Stream.runCollect, - Effect.map((items) => Array.from(items)), - ); - }).pipe( - Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath })), - Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), - ); + expect(imported?._tag).toBe("Importable"); + if (imported?._tag !== "Importable") return; - expect( - threads.flatMap((outcome) => - outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], - ), - ).toEqual(["newer-session"]); - expect(contentReads).toEqual([newerPath]); - expect(openCounts.get(olderPath)).toBe(1); - }), + yield* fixture.replace; + const replaced = yield* scanner + .recentThreads(workspace, [imported.source]) + .pipe(Stream.runCollect); + expect(replaced[0]).toMatchObject({ + _tag: "Importable", + thread: { providerSessionId: "replaced-session" }, + source: { + ...imported.source, + providerSessionId: "replaced-session", + inode: "10000000000000000", + }, + }); + expect(imported.source.inode).toBe("10000000000000001"); + if (replaced[0]?._tag !== "Importable") return; + + const roundedInode = Number(imported.source.inode); + expect(Number.isSafeInteger(roundedInode)).toBe(false); + expect(String(roundedInode)).toBe(replaced[0].source.inode); + for (const inode of [null, roundedInode]) { + const legacy = decodeImportSource({ ...imported.source, inode }); + const opensBeforeBackfill = fixture.opens(); + const backfilled = yield* scanner + .recentThreads(workspace, [legacy]) + .pipe(Stream.runCollect); + expect(backfilled[0]).toMatchObject({ + _tag: "Importable", + thread: { providerSessionId: "replaced-session" }, + source: replaced[0].source, + }); + expect(fixture.opens()).toBe(opensBeforeBackfill + 1); + if (backfilled[0]?._tag !== "Importable") return; + const completed = yield* scanner + .recentThreads(workspace, [backfilled[0].source]) + .pipe(Stream.runCollect); + expect(completed[0]?._tag).toBe("AlreadyImported"); + expect(fixture.opens()).toBe(opensBeforeBackfill + 1); + } + }), ); - it.effect("does not import sessions from a T3-managed worktree", () => + it.effect("imports long Claude tool history with metadata from discarded records", () => Effect.gen(function* () { const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); - const workspace = path.join(configBaseDir, "worktrees", "t3code", "managed-worktree"); - yield* fileSystem.makeDirectory(workspace, { recursive: true }); - + const claudeHomePath = yield* makeTempDir("t3code-long-claude-"); + const codexHomePath = yield* makeTempDir("t3code-long-codex-"); + const workspace = yield* makeTempDir("t3code-long-workspace-"); + const record = (fields: Record) => + encodeTranscriptRecord({ sessionId: "first-session", ...fields }); + const toolResult = record({ + type: "user", + cwd: path.join(workspace, "later-cwd"), + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }], + }, + }); yield* writeTranscript({ - filePath: path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - "rollout-managed.jsonl", - ), + filePath: path.join(claudeHomePath, "projects", "-slug", "long-session.jsonl"), contents: [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "managed-session", cwd: workspace }, + record({ type: "attachment", cwd: workspace, aiTitle: "Old title" }), + record({ type: "user", message: { role: "user", content: "First prompt" } }), + record({ + type: "assistant", + message: { role: "assistant", model: "claude-opus-5-5", content: "First reply" }, }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Do not import this session" }, + // Small tool records exceed the real 32 MiB selected-history budget in aggregate. + ...Array.from({ length: 20_000 }, () => toolResult), + record({ type: "user", message: { role: "user", content: "Latest prompt" } }), + record({ + type: "assistant", + message: { + role: "assistant", + model: "claude-sonnet-5-5", + content: [{ type: "tool_use", id: "toolu_1", name: "Bash", input: {} }], + }, }), + record({ type: "custom-title", customTitle: "Renamed conversation" }), + record({ type: "ai-title", aiTitle: "Newest title" }), + record({ type: "attachment", sessionId: "latest-session" }), ].join("\n"), mtimeMs: nowMs, }); @@ -2381,60 +1485,92 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { const threads = yield* runRecentThreads({ claudeHomePath, codexHomePath, - configBaseDir, workspaceRoot: workspace, }); - expect(threads).toEqual([]); + expect(threads).toMatchObject([ + { + providerSessionId: "latest-session", + title: "Renamed conversation", + model: "claude-sonnet-5-5", + messages: [ + { role: "user", text: "First prompt" }, + { role: "assistant", text: "First reply" }, + { role: "user", text: "Latest prompt" }, + ], + }, + ]); }), ); - it.effect("uses one deterministic provider instance for a shared session home", () => + it.effect("skips growth during reading without exceeding the reserved bytes", () => Effect.gen(function* () { const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); yield* TestClock.setTime(nowMs); const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const sharedHome = yield* makeTempDir("t3code-codex-shared-"); const workspace = yield* makeTempDir("t3code-workspace-"); - - yield* writeTranscript({ - filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"), - contents: [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "shared-session", cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Use the shared session" }, - }), - ].join("\n"), - mtimeMs: nowMs, + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-growing.jsonl", + ); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "growing-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import a changing file" }, + }), + ].join("\n"); + yield* writeTranscript({ filePath: transcriptPath, contents, mtimeMs: nowMs }); + let transcriptOpenCount = 0; + let fullReadBytes = 0; + let grew = false; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath !== transcriptPath) return fileSystem.open(filePath, options); + transcriptOpenCount += 1; + if (transcriptOpenCount === 1) return fileSystem.open(filePath, options); + return fileSystem.open(filePath, options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: (size: number) => + file.readAlloc(size).pipe( + Effect.tap((chunk) => + Effect.gen(function* () { + if (chunk._tag === "None") return; + fullReadBytes += chunk.value.byteLength; + if (!grew) { + grew = true; + yield* fileSystem.writeFileString(filePath, `${contents}\nchanged`); + } + }), + ), + ), + })), + ); + }, }); - const threads = yield* runRecentThreads({ + const outcomes = yield* runRecentThreadOutcomes({ claudeHomePath, codexHomePath, workspaceRoot: workspace, - providerInstances: { - [ProviderInstanceId.make("codex")]: { - driver: ProviderDriverKind.make("codex"), - config: { homePath: sharedHome }, - }, - [ProviderInstanceId.make("codex-personal")]: { - driver: ProviderDriverKind.make("codex"), - config: { homePath: sharedHome }, - }, - [ProviderInstanceId.make("codex-work")]: { - driver: ProviderDriverKind.make("codex"), - config: { homePath: sharedHome }, - }, - }, - }); + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); - expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex"]); + expect(transcriptOpenCount).toBe(2); + expect(fullReadBytes).toBe(new TextEncoder().encode(contents).byteLength); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); }), ); @@ -2482,88 +1618,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex-work"]); }), ); - - it.effect("keeps a second account when the first has 5000 newer files", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const oldWorkspace = yield* makeTempDir("t3code-workspace-old-"); - const recentWorkspace = yield* makeTempDir("t3code-workspace-recent-"); - const recentHome = yield* makeTempDir("t3code-claude-recent-home-"); - const oldDirectory = path.join(claudeHomePath, "projects", "-aaa-old"); - const oldTranscript = path.join(oldDirectory, "old.jsonl"); - const recentDirectory = path.join(recentHome, "projects", "-zzz-recent"); - - yield* writeTranscript({ - filePath: oldTranscript, - contents: encodeTranscriptRecord({ - type: "user", - cwd: oldWorkspace, - sessionId: "old-session", - message: { role: "user", content: "Old work" }, - }), - mtimeMs: nowMs, - }); - yield* writeTranscript({ - filePath: path.join(recentDirectory, "recent.jsonl"), - contents: encodeTranscriptRecord({ - type: "user", - cwd: recentWorkspace, - sessionId: "recent-session", - message: { role: "user", content: "Recent work" }, - }), - mtimeMs: nowMs - 1_000, - }); - - const simulatedOldTranscripts = Array.from( - { length: 5_000 }, - (_, index) => `old-${index}.jsonl`, - ); - const resolveTranscript = (filePath: string) => - path.dirname(filePath) === oldDirectory && path.basename(filePath).startsWith("old-") - ? oldTranscript - : filePath; - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - readDirectory: (directory, options) => - directory === oldDirectory - ? Effect.succeed(simulatedOldTranscripts) - : fileSystem.readDirectory(directory, options), - stat: (filePath) => fileSystem.stat(resolveTranscript(filePath)), - open: (filePath, options) => fileSystem.open(resolveTranscript(filePath), options), - }); - - const input = { - claudeHomePath, - codexHomePath, - providerInstances: { - [ProviderInstanceId.make("claude-work")]: { - driver: ProviderDriverKind.make("claudeAgent"), - config: { homePath: recentHome }, - }, - }, - }; - const threads = yield* Effect.gen(function* () { - const scanner = yield* AgentSessionScanner.AgentSessionScanner; - const scan = yield* scanner.scan; - expect(scan.truncated).toBe(true); - return yield* scanner.recentThreads(recentWorkspace).pipe(Stream.runCollect); - }).pipe( - Effect.provide(layerScannerTest(input)), - Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), - ); - - expect( - threads.flatMap((outcome) => - outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], - ), - ).toEqual(["recent-session"]); - }), - ); }); }); @@ -2648,6 +1702,40 @@ describe("parseAgentSessionTranscript", () => { }); }); + it.each([ + { customTitles: ["Refactor auth module"], expected: "Refactor auth module" }, + // Claude writes an empty customTitle when the user clears a rename. + { customTitles: ["Refactor auth module", ""], expected: "Generated title" }, + ])("uses Claude custom titles $customTitles over the AI title", ({ customTitles, expected }) => { + const aiTitle = JSON.stringify({ + type: "ai-title", + aiTitle: "Generated title", + sessionId: "claude-session", + }); + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + JSON.stringify({ + type: "user", + sessionId: "claude-session", + timestamp: "2026-08-24T10:00:00.000Z", + message: { role: "user", content: "/effort" }, + }), + // Claude rewrites both records on every metadata flush with ai-title + // last, so a later ai-title must not override the user's rename. + ...customTitles.flatMap((customTitle) => [ + JSON.stringify({ type: "custom-title", customTitle, sessionId: "claude-session" }), + aiTitle, + ]), + ].join("\n"), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.title).toBe(expected); + }); + it("drops injected Codex instructions while keeping the visible user event", () => { const thread = AgentSessionScanner.parseAgentSessionTranscript({ contents: [ @@ -3064,53 +2152,6 @@ describe("parseAgentSessionTranscript", () => { ]); }); - it("preserves a canonical Codex event that starts with context markup", () => { - const prompt = - "\n/tmp/project\n\n\nCreate a useful project."; - const thread = AgentSessionScanner.parseAgentSessionTranscript({ - contents: [ - encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { - type: "user_message", - message: prompt, - }, - }), - ].join("\n"), - source: "codex", - providerInstanceId: ProviderInstanceId.make("codex"), - fallbackSessionId: "fallback", - lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), - }); - - expect(thread?.title).toBe("Create a useful project."); - expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); - }); - - it("preserves a Codex request heading in a canonical event", () => { - const prompt = "\n ## My request for Codex:\n\nFix the visible bug"; - const thread = AgentSessionScanner.parseAgentSessionTranscript({ - contents: [ - encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { - type: "user_message", - message: prompt, - }, - }), - ].join("\n"), - source: "codex", - providerInstanceId: ProviderInstanceId.make("codex"), - fallbackSessionId: "fallback", - lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), - }); - - expect(thread?.title).toBe("Fix the visible bug"); - expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); - }); - it("titles a Codex event after the request that follows injected setup blocks", () => { const prompt = [ "\nGitHub\n", @@ -3136,26 +2177,6 @@ describe("parseAgentSessionTranscript", () => { expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); }); - it("keeps context markup quoted inside visible Codex user text", () => { - const quoted = - "Do not remove this example:\n\n/tmp/example\n"; - const thread = AgentSessionScanner.parseAgentSessionTranscript({ - contents: [ - encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: quoted }, - }), - ].join("\n"), - source: "codex", - providerInstanceId: ProviderInstanceId.make("codex"), - fallbackSessionId: "fallback", - lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), - }); - - expect(thread?.messages.map((message) => message.text)).toEqual([quoted]); - }); - it("skips sessions without a visible user message", () => { const thread = AgentSessionScanner.parseAgentSessionTranscript({ contents: JSON.stringify({ @@ -3170,32 +2191,4 @@ describe("parseAgentSessionTranscript", () => { expect(thread).toBeNull(); }); - - it("keeps the first prompt when later assistant output exceeds the message limit", () => { - const transcript = [ - encodeTranscriptRecord({ - type: "user", - sessionId: "claude-session", - message: { role: "user", content: "Keep this prompt" }, - }), - ...Array.from({ length: 250 }, (_, index) => - encodeTranscriptRecord({ - type: "assistant", - message: { role: "assistant", content: `Assistant update ${index}` }, - }), - ), - ].join("\n"); - - const thread = AgentSessionScanner.parseAgentSessionTranscript({ - contents: transcript, - source: "claudeAgent", - providerInstanceId: ProviderInstanceId.make("claudeAgent"), - fallbackSessionId: "fallback", - lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), - }); - - expect(thread?.messages).toHaveLength(200); - expect(thread?.messages[0]?.text).toBe("Keep this prompt"); - expect(thread?.messages.at(-1)?.text).toBe("Assistant update 249"); - }); }); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 5b22f874b6eb..e516ce0e53b9 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -26,6 +26,7 @@ import * as Stream from "effect/Stream"; import { normalizeGitRemoteUrl, + isBareGitConfig, parseGitHubRepositoryNameWithOwnerFromRemoteUrl, parseOriginUrlFromGitConfig, } from "@t3tools/shared/git"; @@ -88,6 +89,7 @@ const TranscriptRecord = Schema.Struct({ cwd: Schema.optional(Schema.String), sessionId: Schema.optional(Schema.String), aiTitle: Schema.optional(Schema.String), + customTitle: Schema.optional(Schema.String), isSidechain: Schema.optional(Schema.Boolean), isMeta: Schema.optional(Schema.Boolean), isCompactSummary: Schema.optional(Schema.Boolean), @@ -214,16 +216,17 @@ function splitTranscriptRecords(contents: string, limit: number): string[] { return records.split("\n", limit); } +function isTextContentBlock(block: typeof TranscriptContentBlock.Type): boolean { + return block.type === "text" || block.type === "input_text" || block.type === "output_text"; +} + function extractText( content: string | ReadonlyArray | undefined, ): string { if (typeof content === "string") return content.trim(); if (content === undefined) return ""; return content - .filter( - (block) => - block.type === "text" || block.type === "input_text" || block.type === "output_text", - ) + .filter(isTextContentBlock) .map((block) => block.text?.trim() ?? "") .filter((text) => text.length > 0) .join("\n"); @@ -247,6 +250,40 @@ function codexTurnId(metadata: unknown): string | null { return decoded.value.turn_id; } +function isHiddenClaudeRecord(record: DecodedTranscriptRecord): boolean { + return record.isSidechain === true || record.isMeta === true || record.isCompactSummary === true; +} + +// Claude repeats session fields on records that need not be retained. +interface ClaudeFields { + readonly sessionId: string | undefined; + readonly title: string | undefined; + readonly customTitle: string | null; + readonly model: string | undefined; +} + +const emptyClaudeFields: ClaudeFields = { + sessionId: undefined, + title: undefined, + customTitle: null, + model: undefined, +}; + +function foldClaudeFields(fields: ClaudeFields, record: DecodedTranscriptRecord): ClaudeFields { + if (isHiddenClaudeRecord(record)) return fields; + const model = record.message?.model?.trim(); + return { + sessionId: record.sessionId?.trim() || fields.sessionId, + title: record.aiTitle?.trim() || fields.title, + // Claude flushes the AI title after the user's rename; an empty rename clears it. + customTitle: + record.customTitle === undefined ? fields.customTitle : record.customTitle.trim() || null, + // Claude uses this sentinel for local error responses. It is not a + // model ID that can be selected when the imported session resumes. + model: model && model !== "" ? model : fields.model, + }; +} + // Codex records setup context as user text ahead of the real request. Skip // these leading blocks when deriving a title; imported messages keep them. const CODEX_TITLE_PREAMBLE = @@ -265,20 +302,26 @@ export function parseAgentSessionTranscript( ): AgentSessionThread | null { if (lines.length > MAX_IMPORT_RECORDS) return null; const records = lines.flatMap((line) => Option.toArray(decodeTranscriptRecord(line))); - return parseAgentSessionRecords(input, records); + const claudeFields = + input.source === "claudeAgent" + ? records.reduce(foldClaudeFields, emptyClaudeFields) + : emptyClaudeFields; + return parseAgentSessionRecords(input, records, claudeFields); } function parseAgentSessionRecords( input: AgentSessionTranscriptMetadata, records: ReadonlyArray, + claudeFields: ClaudeFields, ): AgentSessionThread | null { const fallbackTimestamp = DateTime.formatIso(DateTime.makeUnsafe(input.lastActiveAtMs)); // Claude filenames are session IDs. Codex rollout filenames include extra // timestamp text, so only transcript metadata can provide a resumable ID. - let providerSessionId = input.source === "codex" ? "" : input.fallbackSessionId; - let title: string | null = null; + let providerSessionId = + input.source === "codex" ? "" : (claudeFields.sessionId ?? input.fallbackSessionId); + const title = claudeFields.title ?? null; + let model = claudeFields.model ?? null; let derivedTitle: string | undefined; - let model: string | null = null; let hasCodexSessionId = false; const messages: Array = []; let firstUserMessage: @@ -370,23 +413,8 @@ function parseAgentSessionRecords( for (const record of records) { recordIndex += 1; if (input.source === "claudeAgent") { - if ( - record.isSidechain === true || - record.isMeta === true || - record.isCompactSummary === true - ) { - continue; - } - if (record.sessionId?.trim()) providerSessionId = record.sessionId.trim(); - if (record.aiTitle?.trim()) title = record.aiTitle.trim(); - const messageModel = record.message?.model?.trim(); - // Claude uses this sentinel for local error responses. It is not a - // model ID that can be selected when the imported session resumes. - if (messageModel && messageModel !== "") model = messageModel; - if (record.type !== "user" && record.type !== "assistant") { - continue; - } - + if (isHiddenClaudeRecord(record)) continue; + if (record.type !== "user" && record.type !== "assistant") continue; const text = extractText(record.message?.content); if (text.length === 0) continue; retainMessage({ @@ -471,7 +499,7 @@ function parseAgentSessionRecords( source: input.source, providerInstanceId: input.providerInstanceId, providerSessionId, - title: title ?? derivedTitle ?? "Imported thread", + title: claudeFields.customTitle ?? title ?? derivedTitle ?? "Imported thread", model, createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp, updatedAt: fallbackTimestamp, @@ -488,16 +516,18 @@ function shouldRetainDecodedRecord( source: AgentSessionSource, record: DecodedTranscriptRecord, ): boolean { - if (extractDecodedCwd(record) !== null) return true; if (source === "claudeAgent") { - return ( - record.type === "user" || - record.type === "assistant" || - record.sessionId !== undefined || - record.aiTitle !== undefined || - record.message?.model !== undefined - ); + if (isHiddenClaudeRecord(record) || (record.type !== "user" && record.type !== "assistant")) { + return false; + } + const content = record.message?.content; + return typeof content === "string" + ? content.trim().length > 0 + : (content?.some( + (block) => isTextContentBlock(block) && (block.text?.trim().length ?? 0) > 0, + ) ?? false); } + if (extractDecodedCwd(record) !== null) return true; return ( record.type === "session_meta" || record.type === "turn_context" || @@ -525,7 +555,8 @@ function isT3ManagedWorktree( ); } -function extractCwd(line: string): string | null { +// false stops metadata scanning for an excluded rollout, even if later records contain a cwd. +function extractCwd(line: string, source: AgentSessionSource): string | null | false { let parsed: unknown; try { parsed = JSON.parse(line); @@ -540,6 +571,17 @@ function extractCwd(line: string): string | null { } const payload = record.payload; if (typeof payload === "object" && payload !== null) { + if (source === "codex" && record.type === "session_meta") { + const sessionSource = (payload as Record).source; + if ( + typeof sessionSource === "object" && + sessionSource !== null && + !Array.isArray(sessionSource) && + (Object.hasOwn(sessionSource, "subagent") || Object.hasOwn(sessionSource, "subAgent")) + ) { + return false; + } + } const nested = (payload as Record).cwd; if (typeof nested === "string" && nested.trim().length > 0) { return nested; @@ -554,7 +596,7 @@ function transcriptIdentity(filePath: string, stats: FileSystem.File.Info) { size: Number(stats.size), mtimeMs: Option.match(stats.mtime, { onNone: () => null, onSome: (date) => date.getTime() }), device: Number(stats.dev), - inode: Option.match(stats.ino, { onNone: () => null, onSome: Number }), + inode: Option.match(stats.ino, { onNone: () => null, onSome: String }), birthtimeMs: Option.match(stats.birthtime, { onNone: () => null, onSome: (date) => date.getTime(), @@ -562,16 +604,27 @@ function transcriptIdentity(filePath: string, stats: FileSystem.File.Info) { }; } -function sameTranscriptIdentity( - left: ReturnType, - right: ReturnType, -): boolean { +type TranscriptIdentity = Pick< + AgentSessionImportSource, + "filePath" | "size" | "mtimeMs" | "device" | "inode" | "birthtimeMs" +>; + +const fileIdKey = (inode: TranscriptIdentity["inode"]) => (inode === null ? null : String(inode)); + +function sameTranscriptIdentity(left: TranscriptIdentity, right: TranscriptIdentity): boolean { + // Unsafe legacy numbers may already have lost file ID precision. + if ( + (typeof left.inode === "number" && !Number.isSafeInteger(left.inode)) || + (typeof right.inode === "number" && !Number.isSafeInteger(right.inode)) + ) { + return false; + } return ( left.filePath === right.filePath && left.size === right.size && left.mtimeMs === right.mtimeMs && left.device === right.device && - left.inode === right.inode && + fileIdKey(left.inode) === fileIdKey(right.inode) && left.birthtimeMs === right.birthtimeMs ); } @@ -635,11 +688,6 @@ export const make = Effect.gen(function* () { return `path:${normalizeProjectPathForComparison(realPath)}`; }); - /** - * When it points into a `worktrees/` directory the checkout is a linked worktree, which - * onboarding skips because its history belongs to the main checkout. Submodules use the same - * pointer shape but live under `modules/`, and are offered like any other repository. - */ const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* ( directory: string, ): Effect.fn.Return< @@ -658,11 +706,21 @@ export const make = Effect.gen(function* () { const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); if (target === undefined || target.length === 0) return { _tag: "NotGit" } as const; gitDir = path.resolve(directory, target); - if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) return { _tag: "Worktree" } as const; + } + const linkedWorktree = + gitStats.value.type !== "Directory" && /[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir); + if (linkedWorktree) { + const commonDir = yield* fileSystem + .readFileString(path.join(gitDir, "commondir")) + .pipe(Effect.orElseSucceed(() => "")); + if (commonDir.trim().length === 0) return { _tag: "Worktree" } as const; + gitDir = path.resolve(gitDir, commonDir.trim()); } const configText = yield* fileSystem .readFileString(path.join(gitDir, "config")) .pipe(Effect.orElseSucceed(() => "")); + // Bare repositories have no main checkout, so offer their linked worktrees independently. + if (linkedWorktree && !isBareGitConfig(configText)) return { _tag: "Worktree" } as const; const originUrl = parseOriginUrlFromGitConfig(configText); return { _tag: "Repository", @@ -674,6 +732,7 @@ export const make = Effect.gen(function* () { }); const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( + source: AgentSessionSource, transcript: TranscriptCandidate, budget: MetadataReadBudget, ) { @@ -710,7 +769,9 @@ export const make = Effect.gen(function* () { }; const readLastRecord = () => { const record = remaining + decoder.decode(); - return record.length === 0 || !reserveRecord() ? null : extractCwd(record.trim()); + return record.length === 0 || !reserveRecord() + ? null + : extractCwd(record.trim(), source); }; while (bytesRead < maxBytes) { @@ -737,7 +798,7 @@ export const make = Effect.gen(function* () { for (const line of lines) { if (!reserveRecord()) return null; - const cwd = extractCwd(line.trim()); + const cwd = extractCwd(line.trim(), source); if (cwd !== null) return cwd; } } @@ -769,6 +830,8 @@ export const make = Effect.gen(function* () { return null; } const records: Array = []; + let cwd = Option.none(); + let claudeFields = emptyClaudeFields; let historyBytes = 0; let recordBytes = 0; let recordCount = 0; @@ -790,9 +853,15 @@ export const make = Effect.gen(function* () { recordCount += 1; if (recordCount > recordLimit) return false; const decoded = decodeTranscriptValue(reader.finish()); - if (Option.isSome(decoded) && shouldRetainDecodedRecord(source, decoded.value)) { - records.push(decoded.value); - historyBytes += recordBytes; + if (Option.isSome(decoded)) { + if (Option.isNone(cwd)) cwd = Option.fromNullOr(extractDecodedCwd(decoded.value)); + if (source === "claudeAgent") { + claudeFields = foldClaudeFields(claudeFields, decoded.value); + } + if (shouldRetainDecodedRecord(source, decoded.value)) { + records.push(decoded.value); + historyBytes += recordBytes; + } } recordBytes = 0; reader = createTranscriptJsonReader(reserve, selectTranscriptPath); @@ -828,7 +897,7 @@ export const make = Effect.gen(function* () { if (recordStarted && !(yield* Effect.try(finishRecord))) return null; return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat)) - ? { records, recordCount } + ? { records, recordCount, cwd, claudeFields } : null; }), ), @@ -986,8 +1055,8 @@ export const make = Effect.gen(function* () { >(); for (const transcript of transcripts) { - const cwd = yield* readCwd(transcript, budget); - if (cwd === null) continue; + const cwd = yield* readCwd(source, transcript, budget); + if (cwd === null || cwd === false) continue; const key = `${transcript.providerInstanceId}\0${cwd}`; const existing = byOwnerAndCwd.get(key); if (existing) { @@ -1349,15 +1418,10 @@ export const make = Effect.gen(function* () { } recordsRemaining -= snapshot.recordCount; - let snapshotCwd: string | null = null; - for (const record of snapshot.records) { - snapshotCwd = extractDecodedCwd(record); - if (snapshotCwd !== null) break; - } - if (snapshotCwd === null) { + if (Option.isNone(snapshot.cwd)) { return Option.some({ _tag: "Skipped" }); } - const expandedCwd = expandHomePath(snapshotCwd.trim(), homeDir); + const expandedCwd = expandHomePath(snapshot.cwd.value.trim(), homeDir); if ( !path.isAbsolute(expandedCwd) || (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity @@ -1373,6 +1437,7 @@ export const make = Effect.gen(function* () { lastActiveAtMs: transcript.mtimeMs, }, snapshot.records, + snapshot.claudeFields, ); if (parsedThread === null) { return Option.some({ _tag: "Skipped" }); diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index 453cf4431bdc..86dbcc5beb61 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -1,12 +1,6 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { - CommandId, - GitCommandError, - ProjectId, - ThreadId, - VcsProcessSpawnError, -} from "@t3tools/contracts"; +import { CommandId, ProjectId, ThreadId, VcsProcessSpawnError } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -47,6 +41,7 @@ const layerEnrichment = ProjectEnrichmentService.layer.pipe( resolve: () => Effect.succeed(null), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: () => Effect.succeed(null), }), ), @@ -114,15 +109,6 @@ const requireRoot = Effect.gen(function* () { return Option.getOrThrow(yield* scratch.scratchRoot); }); -it.effect("offers a Scratch folder under the data dir when it is outside a checkout", () => - withScratch(({ baseDir }) => - Effect.gen(function* () { - const path = yield* Path.Path; - assert.equal(yield* requireRoot, path.resolve(baseDir, "scratch")); - }), - ), -); - it.effect("offers nothing when the data dir sits inside a Git checkout", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -134,6 +120,7 @@ it.effect("offers nothing when the data dir sits inside a Git checkout", () => yield* Effect.gen(function* () { const scratch = yield* ManagedProjectFolders.ManagedProjectFolders; assert.isTrue(Option.isNone(yield* scratch.scratchRoot)); + assert.isFalse(yield* scratch.isScratchProject(ProjectId.make("project:any"))); const failure = yield* Effect.flip(scratch.ensureScratchProject); assert.equal(failure._tag, "ScratchUnavailableError"); }).pipe(Effect.provide(layer(baseDir))); @@ -279,7 +266,7 @@ it.effect("leaves threads in other projects alone", () => const projects = yield* ProjectService.ProjectService; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* scratch.ensureScratchProject; + const { projectId: scratchId } = yield* scratch.ensureScratchProject; const other = yield* projects.create({ commandId: CommandId.make("command:other"), projectId: ProjectId.make("project:other"), @@ -295,6 +282,8 @@ it.effect("leaves threads in other projects alone", () => }); assert.isTrue(Option.isNone(folder)); assert.deepEqual(yield* fileSystem.readDirectory(yield* requireRoot), []); + assert.isTrue(yield* scratch.isScratchProject(scratchId)); + assert.isFalse(yield* scratch.isScratchProject(other.id)); }), ), ); @@ -544,36 +533,3 @@ it.effect("removes the folder when the create is cancelled before the project ex ); }), ); - -it.effect("removes the folder when the repository cannot be made", () => - withScratch( - ({ baseDir }) => - Effect.gen(function* () { - const folders = yield* ManagedProjectFolders.ManagedProjectFolders; - const projects = yield* ProjectService.ProjectService; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const failure = yield* Effect.flip(folders.createNamedProject({ name: "Broken" })); - - assert.equal(failure._tag, "NamedProjectFolderError"); - assert.equal(failure.message, "Failed to create the project folder."); - assert.deepEqual(yield* fileSystem.readDirectory(path.resolve(baseDir, "projects")), []); - assert.deepEqual((yield* projects.snapshot).projects, []); - }), - { - git: Layer.mock(GitVcsDriver.GitVcsDriver)({ - readConfigValue: () => Effect.succeed(null), - execute: (input) => - Effect.fail( - new GitCommandError({ - operation: input.operation, - command: "git", - cwd: input.cwd, - detail: "git is broken", - }), - ), - }), - }, - ), -); diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index 406758cfab41..449d7b8e00fc 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -91,6 +91,7 @@ export class ManagedProjectFolders extends Context.Service< { readonly scratchRoot: Effect.Effect>; readonly ensureScratchProject: Effect.Effect<{ readonly projectId: ProjectId }, ScratchError>; + readonly isScratchProject: (projectId: ProjectId) => Effect.Effect; readonly folderForThread: (input: { readonly projectId: ProjectId; readonly threadId: ThreadId; @@ -308,7 +309,7 @@ const make = Effect.gen(function* () { }, ); - const isScratchProject = (projectId: ProjectId, root: string) => + const isScratchRoot = (projectId: ProjectId, root: string) => projects.getById(projectId).pipe( Effect.map( Option.exists((project) => path.resolve(project.workspaceRoot) === path.resolve(root)), @@ -316,12 +317,22 @@ const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); + const isScratchProject: ManagedProjectFolders["Service"]["isScratchProject"] = (projectId) => + scratchRoot.pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(false), + onSome: (root) => isScratchRoot(projectId, root), + }), + ), + ); + const folderForThread: ManagedProjectFolders["Service"]["folderForThread"] = Effect.fn( "ManagedProjectFolders.folderForThread", )(function* (input) { const root = yield* scratchRoot; if (Option.isNone(root)) return Option.none(); - if (!(yield* isScratchProject(input.projectId, root.value))) return Option.none(); + if (!(yield* isScratchRoot(input.projectId, root.value))) return Option.none(); const date = DateTime.formatIso(yield* DateTime.now).slice(0, 10); const words = folderWords(input.text); const id = input.threadId.toLowerCase().replace(/[^a-z0-9]/g, ""); @@ -467,6 +478,7 @@ const make = Effect.gen(function* () { return ManagedProjectFolders.of({ scratchRoot, ensureScratchProject, + isScratchProject, folderForThread, namedProjectsRoot, createNamedProject, diff --git a/apps/server/src/project/ProjectCloneTracker.test.ts b/apps/server/src/project/ProjectCloneTracker.test.ts index 4dbbb0121752..6a13af9ef944 100644 --- a/apps/server/src/project/ProjectCloneTracker.test.ts +++ b/apps/server/src/project/ProjectCloneTracker.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { OrchestrationDispatchCommandError, ProjectId, + projectCloneProgressSummary, SourceControlRepositoryError, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -105,6 +106,45 @@ describe("ProjectCloneTracker", () => { }).pipe(Effect.provide(harness.layer)); }); + it.effect("flags a clone stalled when git goes quiet and clears it when git speaks", () => { + const release = Deferred.makeUnsafe(); + const resumed = Deferred.makeUnsafe(); + const harness = makeHarness({ + clone: (input, options) => + Effect.gen(function* () { + const report = options?.onProgress ?? (() => Effect.void); + yield* report({ stage: "receiving", percent: 24, detail: "168.80 MiB | 20.69 MiB/s" }); + yield* Deferred.await(resumed); + yield* report({ stage: "receiving", percent: 25, detail: "170.00 MiB | 1.00 MiB/s" }); + yield* Deferred.await(release); + return { cwd: input.destinationPath, remoteUrl: input.remoteUrl ?? "", repository: null }; + }), + }); + return Effect.gen(function* () { + const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; + yield* tracker.start(startInput, harness.hooks); + yield* Effect.yieldNow; + expect((yield* tracker.get(projectId))?.stalled).toBe(false); + + yield* TestClock.adjust("19 seconds"); + expect((yield* tracker.get(projectId))?.stalled).toBe(false); + yield* TestClock.adjust("1 second"); + const stalled = yield* tracker.get(projectId); + expect(stalled).toMatchObject({ stalled: true, percent: 24 }); + expect(projectCloneProgressSummary(stalled!)).toBe("Receiving objects · 24% · Stalled"); + + yield* Deferred.succeed(resumed, undefined); + yield* Effect.yieldNow; + expect(yield* tracker.get(projectId)).toMatchObject({ stalled: false, percent: 25 }); + + yield* TestClock.adjust("20 seconds"); + expect((yield* tracker.get(projectId))?.stalled).toBe(true); + yield* Deferred.succeed(release, undefined); + yield* Effect.yieldNow; + expect(yield* tracker.get(projectId)).toMatchObject({ phase: "done", stalled: false }); + }).pipe(Effect.provide(harness.layer)); + }); + it.effect("keeps a failed clone with git's own explanation and retries it", () => { let attempts = 0; const harness = makeHarness({ diff --git a/apps/server/src/project/ProjectCloneTracker.ts b/apps/server/src/project/ProjectCloneTracker.ts index e68ddf78b3fc..411d84fb3859 100644 --- a/apps/server/src/project/ProjectCloneTracker.ts +++ b/apps/server/src/project/ProjectCloneTracker.ts @@ -20,6 +20,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -67,6 +68,10 @@ export interface ProjectCloneHooks { } const DONE_RETENTION = "30 seconds"; +// Git prints nothing while a transfer waits on the network, and the kernel can +// take 15+ minutes to give up on a dead connection. Without this the banner +// keeps showing the last transfer speed as if data were still arriving. +const STALL_AFTER = "20 seconds"; function clampText(text: string, maxLength: number): string { return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`; @@ -133,20 +138,21 @@ export const make = Effect.gen(function* () { return next; }).pipe(Effect.andThen(publish)); - const scheduleRemoval = (projectId: ProjectId) => - Effect.gen(function* () { - yield* clearRetention(projectId); - const fiber = yield* remove(projectId).pipe( - Effect.delay(DONE_RETENTION), - Effect.ensuring( - Effect.sync(() => { - if (retentionFibers.get(projectId) === fiber) retentionFibers.delete(projectId); - }), - ), - Effect.forkDetach, - ); - retentionFibers.set(projectId, fiber); - }); + const scheduleRemoval = Effect.fn("ProjectCloneTracker.scheduleRemoval")(function* ( + projectId: ProjectId, + ) { + yield* clearRetention(projectId); + const fiber = yield* remove(projectId).pipe( + Effect.delay(DONE_RETENTION), + Effect.ensuring( + Effect.sync(() => { + if (retentionFibers.get(projectId) === fiber) retentionFibers.delete(projectId); + }), + ), + Effect.forkDetach, + ); + retentionFibers.set(projectId, fiber); + }); const progress = ( projectId: ProjectId, @@ -164,29 +170,64 @@ export const make = Effect.gen(function* () { percent: update.percent, detail: update.detail === null ? null : clampText(update.detail, PROJECT_CLONE_DETAIL_MAX_LENGTH), + stalled: false, }, })).pipe(Effect.asVoid); - const finish = ( + const markStalled = (projectId: ProjectId) => + modify(projectId, (tracked) => ({ + ...tracked, + snapshot: { ...tracked.snapshot, stalled: true }, + })).pipe(Effect.asVoid); + + /** Marks the clone stalled once, then waits for git to speak again. */ + const watchForStall = (projectId: ProjectId, pulses: Queue.Dequeue) => + Queue.take(pulses).pipe( + Effect.timeoutOption(STALL_AFTER), + Effect.flatMap((pulse) => + Option.isSome(pulse) + ? Effect.void + : markStalled(projectId).pipe(Effect.andThen(Queue.take(pulses))), + ), + Effect.forever, + ); + + const finish = Effect.fn("ProjectCloneTracker.finish")(function* ( projectId: ProjectId, phase: "done" | "failed" | "cancelled", error: string | null, - ) => - Effect.gen(function* () { - const endedAt = yield* nowIso; - yield* modify(projectId, (tracked) => ({ - ...tracked, - fiber: null, - snapshot: { - ...tracked.snapshot, - phase, - endedAt, - percent: phase === "done" ? 100 : tracked.snapshot.percent, - error: error === null ? null : clampText(error, PROJECT_CLONE_ERROR_MAX_LENGTH), - }, - })); - if (phase === "done") yield* scheduleRemoval(projectId); - }); + ) { + const endedAt = yield* nowIso; + yield* modify(projectId, (tracked) => ({ + ...tracked, + fiber: null, + snapshot: { + ...tracked.snapshot, + phase, + endedAt, + percent: phase === "done" ? 100 : tracked.snapshot.percent, + stalled: false, + error: error === null ? null : clampText(error, PROJECT_CLONE_ERROR_MAX_LENGTH), + }, + })); + if (phase === "done") yield* scheduleRemoval(projectId); + }); + + const cloneWatchingForStall = Effect.fn("ProjectCloneTracker.cloneWatchingForStall")(function* ( + projectId: ProjectId, + tracked: TrackedClone, + ) { + const pulses = yield* Queue.sliding(1); + yield* watchForStall(projectId, pulses).pipe(Effect.forkScoped); + return yield* repositories.cloneRepository( + { remoteUrl: tracked.input.cloneUrl, destinationPath: tracked.input.destinationPath }, + { + onProgress: (update) => + Queue.offer(pulses, undefined).pipe(Effect.andThen(progress(projectId, update))), + timeoutMs: null, + }, + ); + }, Effect.scoped); /** * Runs in its own fiber; the tracker records the outcome through `onExit`, @@ -196,41 +237,35 @@ export const make = Effect.gen(function* () { * checkout. */ const runClone = (projectId: ProjectId, tracked: TrackedClone) => - repositories - .cloneRepository( - { remoteUrl: tracked.input.cloneUrl, destinationPath: tracked.input.destinationPath }, - { onProgress: (update) => progress(projectId, update), timeoutMs: null }, - ) - .pipe( - Effect.onExit((exit) => - Exit.isSuccess(exit) - ? finish(projectId, "done", null) - : Cause.hasInterruptsOnly(exit.cause) - ? finish(projectId, "cancelled", null) - : finish(projectId, "failed", describeCloneFailure(exit.cause)), - ), - Effect.flatMap(() => - tracked.hooks - .onCloned({ projectId, workspaceRoot: tracked.input.destinationPath }) - .pipe(Effect.ignoreCause({ log: true })), - ), - Effect.ignoreCause(), - ); + cloneWatchingForStall(projectId, tracked).pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? finish(projectId, "done", null) + : Cause.hasInterruptsOnly(exit.cause) + ? finish(projectId, "cancelled", null) + : finish(projectId, "failed", describeCloneFailure(exit.cause)), + ), + Effect.flatMap(() => + tracked.hooks + .onCloned({ projectId, workspaceRoot: tracked.input.destinationPath }) + .pipe(Effect.ignoreCause({ log: true })), + ), + Effect.ignoreCause(), + ); - const launch = (projectId: ProjectId) => - Effect.gen(function* () { - const current = yield* Ref.get(clones); - const tracked = current.get(projectId); - if (!tracked) return; - const fiber = yield* runClone(projectId, tracked).pipe(Effect.forkIn(cloneScope)); - yield* Ref.update(clones, (map) => { - const existing = map.get(projectId); - if (!existing) return map; - const next = new Map(map); - next.set(projectId, { ...existing, fiber }); - return next; - }); + const launch = Effect.fn("ProjectCloneTracker.launch")(function* (projectId: ProjectId) { + const current = yield* Ref.get(clones); + const tracked = current.get(projectId); + if (!tracked) return; + const fiber = yield* runClone(projectId, tracked).pipe(Effect.forkIn(cloneScope)); + yield* Ref.update(clones, (map) => { + const existing = map.get(projectId); + if (!existing) return map; + const next = new Map(map); + next.set(projectId, { ...existing, fiber }); + return next; }); + }); const start: ProjectCloneTracker["Service"]["start"] = Effect.fn("ProjectCloneTracker.start")( function* (input, hooks) { @@ -245,6 +280,7 @@ export const make = Effect.gen(function* () { stage: "connecting", percent: null, detail: null, + stalled: false, error: null, startedAt, endedAt: null, @@ -307,8 +343,8 @@ export const make = Effect.gen(function* () { const get: ProjectCloneTracker["Service"]["get"] = (projectId) => Ref.get(clones).pipe(Effect.map((current) => current.get(projectId)?.snapshot ?? null)); - const cancel: ProjectCloneTracker["Service"]["cancel"] = (projectId) => - Effect.gen(function* () { + const cancel: ProjectCloneTracker["Service"]["cancel"] = Effect.fn("ProjectCloneTracker.cancel")( + function* (projectId) { const current = yield* Ref.get(clones); const tracked = current.get(projectId); if (!tracked || tracked.snapshot.phase !== "running" || !tracked.fiber) return false; @@ -324,10 +360,11 @@ export const make = Effect.gen(function* () { }), ); return true; - }); + }, + ); - const retry: ProjectCloneTracker["Service"]["retry"] = (projectId) => - Effect.gen(function* () { + const retry: ProjectCloneTracker["Service"]["retry"] = Effect.fn("ProjectCloneTracker.retry")( + function* (projectId) { const current = yield* Ref.get(clones); const tracked = current.get(projectId); if ( @@ -346,6 +383,7 @@ export const make = Effect.gen(function* () { stage: "connecting", percent: null, detail: null, + stalled: false, error: null, startedAt, endedAt: null, @@ -353,23 +391,25 @@ export const make = Effect.gen(function* () { })); yield* launch(projectId); return true; - }); + }, + ); - const discard: ProjectCloneTracker["Service"]["discard"] = (projectId) => - Effect.gen(function* () { - const current = yield* Ref.get(clones); - const tracked = current.get(projectId); - if (!tracked) return; - if (tracked.fiber) yield* Fiber.interrupt(tracked.fiber); - // Git may have finished while the interrupt was landing; the project - // is going away either way, but a complete checkout is the user's. - const after = yield* get(projectId); - if (after?.phase !== "done") { - yield* repositories.discardClone(tracked.input.destinationPath).pipe(Effect.ignore); - } - yield* clearRetention(projectId); - yield* remove(projectId); - }); + const discard: ProjectCloneTracker["Service"]["discard"] = Effect.fn( + "ProjectCloneTracker.discard", + )(function* (projectId) { + const current = yield* Ref.get(clones); + const tracked = current.get(projectId); + if (!tracked) return; + if (tracked.fiber) yield* Fiber.interrupt(tracked.fiber); + // Git may have finished while the interrupt was landing; the project + // is going away either way, but a complete checkout is the user's. + const after = yield* get(projectId); + if (after?.phase !== "done") { + yield* repositories.discardClone(tracked.input.destinationPath).pipe(Effect.ignore); + } + yield* clearRetention(projectId); + yield* remove(projectId); + }); const stream: ProjectCloneTracker["Service"]["stream"] = Stream.callback< ReadonlyArray @@ -405,11 +445,15 @@ const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); * client can start a thread on an empty tree, and no attachment copies are made * for a command that is about to be refused. */ -export const rejectCommandsDuringClone = ( - tracker: ProjectCloneTracker["Service"], - command: { readonly type: string; readonly projectId?: ProjectId; readonly bootstrap?: unknown }, -): Effect.Effect => - Effect.gen(function* () { +export const rejectCommandsDuringClone = Effect.fn("ProjectCloneTracker.rejectCommandsDuringClone")( + function* ( + tracker: ProjectCloneTracker["Service"], + command: { + readonly type: string; + readonly projectId?: ProjectId; + readonly bootstrap?: unknown; + }, + ): Effect.fn.Return { const projectId = command.type === "thread.create" ? (command.projectId ?? null) @@ -425,7 +469,8 @@ export const rejectCommandsDuringClone = ( ? "The repository is still being cloned." : "The repository was not cloned. Retry the clone first.", }); - }); + }, +); function bootstrapProjectId(bootstrap: unknown): ProjectId | null { if (typeof bootstrap !== "object" || bootstrap === null) return null; diff --git a/apps/server/src/project/ProjectEnrichmentService.test.ts b/apps/server/src/project/ProjectEnrichmentService.test.ts index 7a0238159a74..492d1c43419a 100644 --- a/apps/server/src/project/ProjectEnrichmentService.test.ts +++ b/apps/server/src/project/ProjectEnrichmentService.test.ts @@ -3,12 +3,8 @@ import type { RepositoryIdentity } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; -import { TestClock } from "effect/testing"; - -import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectEnrichment from "./ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; @@ -48,108 +44,6 @@ const layer = ( Layer.provide(metadataLayer), ); -it.effect("preserves either enrichment field when the other resolver fails", () => - Effect.gen(function* () { - const layerMetadata = Layer.merge( - Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { - resolve: (workspaceRoot) => - workspaceRoot === "/repo-fails" - ? Effect.die("repository resolver failed") - : Effect.succeed(identity(workspaceRoot)), - }), - Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { - resolvePath: (workspaceRoot) => - workspaceRoot === "/favicon-fails" - ? Effect.fail( - new ProjectFaviconResolver.ProjectFaviconResolutionError({ - operation: "stat-candidate", - workspaceRoot, - cause: "favicon resolver failed", - }), - ) - : Effect.succeed(`${workspaceRoot}/favicon.svg`), - }), - ); - - yield* Effect.gen(function* () { - const service = yield* ProjectEnrichment.ProjectEnrichmentService; - yield* service.request("/favicon-fails"); - yield* service.request("/repo-fails"); - - const faviconFailure = yield* waitForAvailable( - service, - "/favicon-fails", - (value) => value.repositoryIdentity !== null, - ); - assert.equal( - faviconFailure.repositoryIdentity?.canonicalKey, - "example.test/v1/favicon-fails", - ); - assert.isNull(faviconFailure.faviconPath); - - const repositoryFailure = yield* waitForAvailable( - service, - "/repo-fails", - (value) => value.faviconPath !== null, - ); - assert.isNull(repositoryFailure.repositoryIdentity); - assert.equal(repositoryFailure.faviconPath, "/repo-fails/favicon.svg"); - }).pipe(Effect.provide(layer(layerMetadata))); - }), -); - -it.effect("does not warn about favicons for workspace roots that no longer exist", () => - Effect.gen(function* () { - const warnings: Array = []; - const logger = Logger.make(({ message }) => { - warnings.push(message); - }); - const metadataLayer = Layer.merge( - Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { - resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)), - }), - Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { - resolvePath: (workspaceRoot) => - Effect.fail( - workspaceRoot === "/missing" - ? new ProjectFaviconResolver.ProjectFaviconResolutionError({ - operation: "normalize-workspace", - workspaceRoot, - cause: new WorkspacePaths.WorkspaceRootNotExistsError({ - workspaceRoot, - normalizedWorkspaceRoot: workspaceRoot, - }), - }) - : new ProjectFaviconResolver.ProjectFaviconResolutionError({ - operation: "stat-candidate", - workspaceRoot, - cause: "favicon resolver failed", - }), - ), - }), - ); - - yield* Effect.gen(function* () { - const service = yield* ProjectEnrichment.ProjectEnrichmentService; - yield* service.request("/missing"); - yield* service.request("/broken"); - - for (let attempt = 0; attempt < 100 && warnings.length === 0; attempt += 1) { - yield* Effect.yieldNow; - } - - assert.lengthOf(warnings, 1); - assert.nestedPropertyVal(warnings[0], "[1].workspaceRoot", "/broken"); - }).pipe( - Effect.provide( - layer(metadataLayer, { concurrency: 1 }).pipe( - Layer.provide(Logger.layer([logger], { mergeWithExisting: false })), - ), - ), - ); - }), -); - it.effect("publishes repository completion while favicon enrichment is still pending", () => Effect.gen(function* () { const releaseFavicon = yield* Deferred.make(); @@ -158,6 +52,7 @@ it.effect("publishes repository completion while favicon enrichment is still pen resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Deferred.await(releaseFavicon).pipe(Effect.as(`${workspaceRoot}/favicon.svg`)), }), @@ -186,6 +81,7 @@ it.effect("keeps repository workers available when every favicon worker is hung" resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: () => Effect.gen(function* () { const started = yield* Ref.updateAndGet(faviconStarts, (count) => count + 1); @@ -224,45 +120,6 @@ it.effect("keeps repository workers available when every favicon worker is hung" }), ); -it.effect("getAvailable returns immediately while repository identity is still unresolved", () => - Effect.gen(function* () { - const repositoryStarted = yield* Deferred.make(); - const releaseRepository = yield* Deferred.make(); - const layerMetadata = Layer.merge( - Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { - resolve: (workspaceRoot) => - Deferred.succeed(repositoryStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseRepository)), - Effect.as(identity(workspaceRoot)), - ), - }), - Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { - resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`), - }), - ); - - yield* Effect.gen(function* () { - const service = yield* ProjectEnrichment.ProjectEnrichmentService; - - const immediate = yield* service.getAvailable("/pending-identity"); - assert.isNull(immediate.repositoryIdentity); - assert.isNull(immediate.faviconPath); - assert.isFalse(immediate.repositoryIdentityResolved); - - yield* Deferred.await(repositoryStarted); - - yield* Deferred.succeed(releaseRepository, undefined); - const resolved = yield* waitForAvailable( - service, - "/pending-identity", - (value) => value.repositoryIdentity !== null, - ); - assert.equal(resolved.repositoryIdentity?.canonicalKey, "example.test/v1/pending-identity"); - assert.isTrue(resolved.repositoryIdentityResolved); - }).pipe(Effect.provide(layer(layerMetadata))); - }), -); - it.effect( "reports successful cached null as resolved while cold and failed lookups stay unresolved", () => @@ -285,6 +142,7 @@ it.effect( }, }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: () => Effect.succeed(null), }), ); @@ -344,6 +202,7 @@ it.effect("deduplicates requests, bounds pending work, and reloads invalidated r }), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`), }), ); @@ -394,88 +253,3 @@ it.effect("deduplicates requests, bounds pending work, and reloads invalidated r ); }), ); - -it.effect("rescans a favicon only after 15 minutes", () => - Effect.gen(function* () { - const faviconScans = yield* Ref.make(0); - const layerMetadata = Layer.merge( - Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { - resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)), - }), - Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { - resolvePath: (workspaceRoot) => - Ref.updateAndGet(faviconScans, (count) => count + 1).pipe( - Effect.map((scan) => `${workspaceRoot}/favicon-${scan}.svg`), - ), - }), - ); - - yield* Effect.gen(function* () { - const service = yield* ProjectEnrichment.ProjectEnrichmentService; - yield* service.getAvailable("/repo"); - yield* waitForAvailable( - service, - "/repo", - (value) => value.faviconPath === "/repo/favicon-1.svg", - ); - - for (let minute = 1; minute < 15; minute += 1) { - yield* TestClock.adjust("1 minute"); - yield* service.getAvailable("/repo"); - } - assert.equal(yield* Ref.get(faviconScans), 1); - - yield* TestClock.adjust("1 minute"); - yield* service.getAvailable("/repo"); - yield* waitForAvailable( - service, - "/repo", - (value) => value.faviconPath === "/repo/favicon-2.svg", - ); - assert.equal(yield* Ref.get(faviconScans), 2); - }).pipe(Effect.provide(layer(layerMetadata))); - }), -); - -it.effect( - "follows repository identity changes within a minute without rescanning the favicon", - () => - Effect.gen(function* () { - const remoteVersion = yield* Ref.make(0); - const faviconScans = yield* Ref.make(0); - const layerMetadata = Layer.merge( - Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { - resolve: (workspaceRoot) => - Ref.get(remoteVersion).pipe( - Effect.map((version) => (version === 0 ? null : identity(workspaceRoot, version))), - ), - }), - Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { - resolvePath: () => Ref.update(faviconScans, (count) => count + 1).pipe(Effect.as(null)), - }), - ); - - yield* Effect.gen(function* () { - const service = yield* ProjectEnrichment.ProjectEnrichmentService; - yield* service.getAvailable("/folder"); - yield* waitForAvailable(service, "/folder", (value) => value.repositoryIdentityResolved); - assert.equal((yield* service.peek("/folder")).repositoryIdentity, null); - - for (const version of [1, 2]) { - yield* Ref.set(remoteVersion, version); - yield* TestClock.adjust("1 minute"); - yield* service.getAvailable("/folder"); - const available = yield* waitForAvailable( - service, - "/folder", - (value) => value.repositoryIdentity?.canonicalKey === `example.test/v${version}/folder`, - ); - assert.equal( - available.repositoryIdentity?.canonicalKey, - `example.test/v${version}/folder`, - ); - } - assert.equal(yield* Ref.get(faviconScans), 1); - }).pipe(Effect.provide(layer(layerMetadata))); - }), -); diff --git a/apps/server/src/project/ProjectEnrichmentService.ts b/apps/server/src/project/ProjectEnrichmentService.ts index efb510e37ef2..1592204816fe 100644 --- a/apps/server/src/project/ProjectEnrichmentService.ts +++ b/apps/server/src/project/ProjectEnrichmentService.ts @@ -283,6 +283,8 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( [ Cache.invalidate(repositoryIdentityCache, workspaceRoot), Cache.invalidate(faviconCache, workspaceRoot), + // The resolver keeps its own misses, which would outlive a clone filling the folder. + faviconResolver.invalidate(workspaceRoot), ], { concurrency: "unbounded", discard: true }, ), diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 1d5bf9441d0d..5877662f46bc 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -1,11 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import { TestClock } from "effect/testing"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -51,27 +49,20 @@ const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { describe("resolvePath", () => { - it.effect("serves repeated resolves from cache instead of re-walking candidates", () => + it.effect("finds a favicon added after a cached miss once the workspace is invalidated", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; const path = yield* Path.Path; const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "public/favicon.svg", "public"); - const resolved = yield* resolver.resolvePath(cwd); - expect(resolved?.endsWith(path.join("public", "favicon.svg"))).toBe(true); - - yield* writeTextFile(cwd, "favicon.svg", "root"); - - for (const _attempt of [1, 2, 3]) { - expect(yield* resolver.resolvePath(cwd)).toBe(resolved); - } - - yield* TestClock.adjust(Duration.minutes(11)); + // A clone's project exists while its folder is still empty. + expect(yield* resolver.resolvePath(cwd)).toBeNull(); + yield* writeTextFile(cwd, "favicon.ico", "icon"); + expect(yield* resolver.resolvePath(cwd)).toBeNull(); - expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.svg")); - expect(yield* resolver.resolvePath(cwd)).not.toBe(resolved); - }).pipe(Effect.provide(TestClock.layer())), + yield* resolver.invalidate(cwd); + expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.ico")); + }), ); it.effect("falls back at once when a cached favicon is deleted", () => @@ -90,35 +81,6 @@ it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { }).pipe(Effect.provide(TestClock.layer())), ); - it.effect("re-probes for a favicon added after a miss once the negative TTL expires", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - - expect(yield* resolver.resolvePath(cwd)).toBeNull(); - - yield* writeTextFile(cwd, "favicon.svg", "favicon"); - expect(yield* resolver.resolvePath(cwd)).toBeNull(); - - yield* TestClock.adjust(Duration.minutes(2)); - - expect(yield* resolver.resolvePath(cwd)).not.toBeNull(); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("prefers well-known favicon files", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.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("prefers a t3.json iconPath over well-known files", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; @@ -178,20 +140,6 @@ it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { }), ); - it.effect("falls back to well-known files when the t3.json iconPath does not exist", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/missing.svg" }'); - yield* writeTextFile(cwd, "favicon.svg", "favicon"); - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).not.toBeNull(); - expect(resolved).toContain("favicon.svg"); - }), - ); - it.effect("ignores invalid t3.json files", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; @@ -259,42 +207,7 @@ it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { }), ); - it.effect("resolves object-literal icon metadata when href precedes rel", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - yield* writeTextFile( - cwd, - "src/root.tsx", - `const links = [{ href: "/brand/logo.svg", rel: "shortcut icon" }];`, - ); - yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).not.toBeNull(); - expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg")); - }), - ); - - it.effect("resolves object-literal icon metadata alongside nested objects", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - yield* writeTextFile( - cwd, - "src/root.tsx", - `const links = [{ attributes: {}, rel: "icon", href: "/brand/logo.svg" }];`, - ); - yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).not.toBeNull(); - expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg")); - }), - ); - + // regression: #5538 favicon resolution pinned the event loop it.effect("skips icon metadata without an href and keeps scanning", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; @@ -313,110 +226,6 @@ it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { }), ); - it.effect("scans large icon sources without an icon in reasonable time", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - const filler = `

${"pokopia companion guide ".repeat(24)}

\n`; - yield* writeTextFile( - cwd, - "index.html", - `guide\n${filler.repeat(1200)}`, - ); - - const startedAt = performance.now(); - const resolved = yield* resolver.resolvePath(cwd); - const elapsedMs = performance.now() - startedAt; - - expect(resolved).toBeNull(); - expect(elapsedMs).toBeLessThan(5_000); - }), - ); - - it.effect("returns null when no icon is present", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).toBeNull(); - }), - ); - - it.effect("preserves workspace normalization context", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - const missingCwd = `${cwd}/missing`; - - const error = yield* resolver.resolvePath(missingCwd).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "ProjectFaviconResolutionError", - operation: "normalize-workspace", - workspaceRoot: missingCwd, - }); - expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspaceRootNotExistsError); - }), - ); - - it.effect("preserves non-missing candidate stat failures", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - const faviconPath = path.join(cwd, "favicon.svg"); - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "stat", - pathOrDescriptor: faviconPath, - }); - const resolver = yield* makeResolverWithFileSystem( - FileSystem.FileSystem.of({ - ...fileSystem, - stat: (filePath) => - filePath === faviconPath ? Effect.fail(cause) : fileSystem.stat(filePath), - }), - ); - - const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "ProjectFaviconResolutionError", - operation: "stat-candidate", - workspaceRoot: cwd, - relativePath: "favicon.svg", - absolutePath: faviconPath, - }); - expect(error.cause).toBe(cause); - }), - ); - - it.effect("probes well-known candidates concurrently", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* makeTempDir; - let inFlight = 0; - let peakInFlight = 0; - const resolver = yield* makeResolverWithFileSystem( - FileSystem.FileSystem.of({ - ...fileSystem, - stat: (filePath) => - Effect.suspend(() => { - inFlight += 1; - peakInFlight = Math.max(peakInFlight, inFlight); - return Effect.yieldNow.pipe(Effect.andThen(fileSystem.stat(filePath))); - }).pipe(Effect.ensuring(Effect.sync(() => (inFlight -= 1)))), - }), - ); - - expect(yield* resolver.resolvePath(cwd)).toBeNull(); - expect(peakInFlight).toBeGreaterThan(1); - }), - ); - it.effect("returns a found favicon without waiting for lower-ranked probes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -435,70 +244,6 @@ it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { }), ); - it.effect("ignores a failing candidate that ranks below a found favicon", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "favicon.svg", "root"); - const failingPath = path.join(cwd, "favicon.ico"); - const resolver = yield* makeResolverWithFileSystem( - FileSystem.FileSystem.of({ - ...fileSystem, - stat: (filePath) => - filePath === failingPath - ? Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "stat", - pathOrDescriptor: failingPath, - }), - ) - : fileSystem.stat(filePath), - }), - ); - - expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.svg")); - }), - ); - - it.effect("preserves icon source read failures", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - const sourcePath = path.join(cwd, "index.html"); - yield* writeTextFile(cwd, "index.html", ''); - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: sourcePath, - }); - const resolver = yield* makeResolverWithFileSystem( - FileSystem.FileSystem.of({ - ...fileSystem, - readFileString: (filePath, options) => - filePath === sourcePath - ? Effect.fail(cause) - : fileSystem.readFileString(filePath, options), - }), - ); - - const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "ProjectFaviconResolutionError", - operation: "read-source", - workspaceRoot: cwd, - relativePath: "index.html", - absolutePath: sourcePath, - }); - expect(error.cause).toBe(cause); - }), - ); - it.effect("skips icon metadata paths outside the workspace", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; @@ -510,20 +255,5 @@ it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { expect(resolved).toBeNull(); }), ); - - it.effect("continues to later sources after an outside-root icon href", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "index.html", ''); - yield* writeTextFile(cwd, "public/index.html", ''); - yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).not.toBeNull(); - expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg")); - }), - ); }); }); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 75dc4e661ef6..e42cb5fbe919 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -111,6 +111,8 @@ export class ProjectFaviconResolver extends Context.Service< cwd: string, faviconPath?: string, ) => Effect.Effect; + /** Forgets cached lookups for a workspace whose files changed, such as a finished clone. */ + readonly invalidate: (cwd: string) => Effect.Effect; } >()("t3/project/ProjectFaviconResolver") {} @@ -238,20 +240,19 @@ export const make = Effect.gen(function* () { // matters when the disk is slow. Fibers are joined in order, so a hit returns // without waiting for lower-ranked probes (the scope interrupts them), and a // failure surfaces only when no earlier probe found a file. - const firstInOrder =
( + const firstInOrder = Effect.fn("ProjectFaviconResolver.firstInOrder")(function* ( items: ReadonlyArray, probe: (item: A) => Effect.Effect, - ) => - Effect.gen(function* () { - const fibers = yield* Effect.forEach(items, (item) => Effect.forkScoped(probe(item))); - for (const fiber of fibers) { - const found = yield* Fiber.join(fiber); - if (found) { - return found; - } + ) { + const fibers = yield* Effect.forEach(items, (item) => Effect.forkScoped(probe(item))); + for (const fiber of fibers) { + const found = yield* Fiber.join(fiber); + if (found) { + return found; } - return null; - }).pipe(Effect.scoped); + } + return null; + }, Effect.scoped); const resolvePathUncached = Effect.fn("ProjectFaviconResolver.resolvePathUncached")(function* ( cwd: string, @@ -341,7 +342,18 @@ export const make = Effect.gen(function* () { return yield* Cache.get(faviconCache, key); }); - return ProjectFaviconResolver.of({ resolvePath }); + const invalidate: ProjectFaviconResolver["Service"]["invalidate"] = (cwd) => + Cache.keys(faviconCache).pipe( + Effect.flatMap((keys) => + Effect.forEach( + Array.from(keys).filter((key) => parseFaviconCacheKey(key).cwd === cwd), + (key) => Cache.invalidate(faviconCache, key), + { discard: true }, + ), + ), + ); + + return ProjectFaviconResolver.of({ resolvePath, invalidate }); }); export const layer = Layer.effect(ProjectFaviconResolver, make); diff --git a/apps/server/src/project/ProjectMutation.test.ts b/apps/server/src/project/ProjectMutation.test.ts deleted file mode 100644 index c2f9ee1a89b1..000000000000 --- a/apps/server/src/project/ProjectMutation.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { CommandId, ProjectId, type Project } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Ref from "effect/Ref"; - -import { projectMutationOperation } from "./ProjectMutation.ts"; -import { type ProjectService } from "./ProjectService.ts"; - -const projectId = ProjectId.make("project:mutation-mapping"); -const project = { - id: projectId, - title: "Mapping", - workspaceRoot: "/work/mapping", - repositoryIdentity: null, - faviconPath: null, - projectIcon: null, - defaultModelSelection: null, - defaultThreadEnvMode: null, - autoPull: false, - scripts: [], - createdAt: "2026-09-05T00:00:00.000Z", - updatedAt: "2026-09-05T00:00:00.000Z", - deletedAt: null, -} satisfies Project; - -it.effect("preserves every project mutation field", () => - Effect.gen(function* () { - const calls = yield* Ref.make>([]); - const projects: Pick = { - create: (input) => - Ref.update(calls, (entries) => [...entries, input]).pipe(Effect.as(project)), - update: (input) => - Ref.update(calls, (entries) => [...entries, input]).pipe(Effect.as(project)), - delete: (input) => - Ref.update(calls, (entries) => [...entries, input]).pipe(Effect.as(project)), - }; - - yield* projectMutationOperation(projects, { - type: "project.create", - commandId: CommandId.make("command:create"), - projectId, - title: "Created", - workspaceRoot: "/work/created", - createWorkspaceRootIfMissing: true, - defaultModelSelection: null, - scripts: [], - }); - yield* projectMutationOperation(projects, { - type: "project.update", - commandId: CommandId.make("command:update"), - projectId, - title: "Updated", - workspaceRoot: "/work/updated", - defaultModelSelection: null, - autoPull: false, - projectIcon: null, - faviconPath: null, - defaultThreadEnvMode: null, - scripts: [], - }); - yield* projectMutationOperation(projects, { - type: "project.delete", - commandId: CommandId.make("command:delete"), - projectId, - force: true, - }); - - assert.deepEqual(yield* Ref.get(calls), [ - { - commandId: "command:create", - projectId, - title: "Created", - workspaceRoot: "/work/created", - createWorkspaceRootIfMissing: true, - defaultModelSelection: null, - scripts: [], - }, - { - commandId: "command:update", - projectId, - title: "Updated", - workspaceRoot: "/work/updated", - defaultModelSelection: null, - autoPull: false, - projectIcon: null, - faviconPath: null, - defaultThreadEnvMode: null, - scripts: [], - }, - { commandId: "command:delete", projectId, force: true }, - ]); - }), -); diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index a35e58224baa..41804842d520 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -58,6 +58,7 @@ const layerServices = Layer.mergeAll( resolve: () => Effect.succeed(null), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: () => Effect.succeed(null), }), ), @@ -203,6 +204,7 @@ it.effect("retries a partial project deletion without repeating child events or partialCleanup.map((effect) => [effect.thread_id, effect.effect_type]), [ [firstThreadId, "preview.cleanup"], + [firstThreadId, "scheduled-tasks.cleanup"], [firstThreadId, "terminal.cleanup"], ], ); @@ -226,7 +228,7 @@ it.effect("retries a partial project deletion without repeating child events or assert.deepEqual(finalEvents[0], partialEvents[0]); assert.equal(finalEvents[2]?.command_id, commandId); const finalCleanup = yield* readCleanup; - assert.lengthOf(finalCleanup, 4); + assert.lengthOf(finalCleanup, 6); assert.deepEqual( finalCleanup.filter((effect) => effect.thread_id === firstThreadId), partialCleanup, @@ -242,6 +244,12 @@ it.effect("retries a partial project deletion without repeating child events or command_id: expectedCommandId, effect_type: "preview.cleanup", }, + { + effect_id: `effect:${expectedCommandId}:scheduled-tasks.cleanup`, + thread_id: threadId, + command_id: expectedCommandId, + effect_type: "scheduled-tasks.cleanup", + }, { effect_id: `effect:${expectedCommandId}:terminal.cleanup`, thread_id: threadId, diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 67e76bfeafbc..9347acef12c8 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -46,6 +46,7 @@ const layerMetadata = Layer.merge( }), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`), }), ); @@ -202,28 +203,6 @@ it.layer(layerTest)("ProjectService", (it) => { }), ); - it.effect("rejects active workspace collisions", () => - Effect.gen(function* () { - const service = yield* ProjectService.ProjectService; - yield* TestClock.setTime(Date.parse("2026-06-20T10:00:00.000Z")); - yield* service.create({ - commandId: CommandId.make("command:collision:first"), - projectId: ProjectId.make("project:collision:first"), - title: "First", - workspaceRoot: "/work/shared", - }); - const error = yield* service - .create({ - commandId: CommandId.make("command:collision:second"), - projectId: ProjectId.make("project:collision:second"), - title: "Second", - workspaceRoot: "/work/shared", - }) - .pipe(Effect.flip); - assert.equal(error._tag, "ProjectConflictError"); - }), - ); - it.effect("auto-bootstraps a workspace exactly once", () => Effect.gen(function* () { const service = yield* ProjectService.ProjectService; @@ -485,111 +464,6 @@ it.layer(layerTest)("ProjectService", (it) => { ); }); -it.effect( - "returns project mutations before slow enrichment and shares the eventual result across reads", - () => - Effect.gen(function* () { - const repositoryStarted = yield* Deferred.make(); - const releaseRepository = yield* Deferred.make(); - const repositoryCalls = yield* Ref.make(0); - const faviconCalls = yield* Ref.make(0); - - const layerSlowMetadata = Layer.merge( - Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { - resolve: (workspaceRoot) => - Effect.gen(function* () { - yield* Ref.update(repositoryCalls, (count) => count + 1); - yield* Deferred.succeed(repositoryStarted, undefined); - yield* Deferred.await(releaseRepository); - return { - canonicalKey: "github.com/t3tools/slow-project", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:t3tools/slow-project.git", - }, - rootPath: workspaceRoot, - }; - }), - }), - Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { - resolvePath: (workspaceRoot) => - Ref.updateAndGet(faviconCalls, (count) => count + 1).pipe( - Effect.as(`${workspaceRoot}/favicon.svg`), - ), - }), - ); - - yield* Effect.gen(function* () { - const service = yield* ProjectService.ProjectService; - const projectId = ProjectId.make("project:slow-enrichment"); - const createFiber = yield* service - .create({ - commandId: CommandId.make("command:slow-enrichment:create"), - projectId, - title: "Slow enrichment", - workspaceRoot: "/work/slow-enrichment", - }) - .pipe(Effect.forkChild({ startImmediately: true })); - - yield* Deferred.await(repositoryStarted); - yield* Effect.yieldNow; - assert.isDefined(createFiber.pollUnsafe()); - - const created = yield* Fiber.join(createFiber); - assert.isNull(created.repositoryIdentity); - assert.isNull(created.faviconPath); - - const updated = yield* service.update({ - commandId: CommandId.make("command:slow-enrichment:update"), - projectId, - title: "Updated before enrichment", - }); - assert.equal(updated.title, "Updated before enrichment"); - assert.isNull(updated.repositoryIdentity); - assert.equal(updated.faviconPath, "/work/slow-enrichment/favicon.svg"); - - const immediateReadFiber = yield* service - .getById(projectId) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.yieldNow; - assert.isDefined(immediateReadFiber.pollUnsafe()); - const immediateRead = Option.getOrThrow(yield* Fiber.join(immediateReadFiber)); - assert.isNull(immediateRead.repositoryIdentity); - assert.equal(immediateRead.faviconPath, "/work/slow-enrichment/favicon.svg"); - - const snapshotFiber = yield* service.snapshot.pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* Effect.yieldNow; - assert.isDefined(snapshotFiber.pollUnsafe()); - const immediateSnapshot = yield* Fiber.join(snapshotFiber); - assert.isNull(immediateSnapshot.projects[0]?.repositoryIdentity ?? null); - - yield* Deferred.succeed(releaseRepository, undefined); - - const firstProject = yield* waitForProject( - service, - projectId, - (project) => project.repositoryIdentity !== null && project.faviconPath !== null, - ); - assert.isDefined(firstProject); - assert.equal( - firstProject.repositoryIdentity?.canonicalKey, - "github.com/t3tools/slow-project", - ); - assert.equal(firstProject.faviconPath, "/work/slow-enrichment/favicon.svg"); - - const byId = Option.getOrThrow(yield* service.getById(projectId)); - const secondSnapshot = yield* service.snapshot; - assert.equal(byId.repositoryIdentity?.canonicalKey, "github.com/t3tools/slow-project"); - assert.equal(secondSnapshot.projects[0]?.faviconPath, "/work/slow-enrichment/favicon.svg"); - assert.equal(yield* Ref.get(repositoryCalls), 1); - assert.equal(yield* Ref.get(faviconCalls), 1); - }).pipe(Effect.provide(layerTestFor(layerSlowMetadata))); - }), -); - it.effect("keeps project snapshots available when optional metadata enrichment fails", () => Effect.gen(function* () { const layerFailingMetadata = Layer.merge( @@ -597,6 +471,7 @@ it.effect("keeps project snapshots available when optional metadata enrichment f resolve: () => Effect.succeed(null), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Effect.fail( new ProjectFaviconResolver.ProjectFaviconResolutionError({ @@ -647,6 +522,7 @@ it.effect("invalidates workspace-derived metadata when a project moves", () => ), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Ref.get(metadataVersion).pipe( Effect.map((version) => `${workspaceRoot}/favicon-v${version}.svg`), diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts deleted file mode 100644 index dcba984ffe97..000000000000 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { assert, it, vi } from "@effect/vitest"; -import { ProjectId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import * as TerminalManager from "../terminal/Manager.ts"; -import * as ServerSettings from "../serverSettings.ts"; -import * as ProjectService from "./ProjectService.ts"; -import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; - -it.effect("resolves setup scripts through the standalone project service", () => { - const open = vi.fn((input: Parameters[0]) => - Effect.succeed({ - threadId: input.threadId, - terminalId: input.terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "running" as const, - pid: 123, - history: "", - exitCode: null, - exitSignal: null, - label: "Shell", - updatedAt: "2026-06-20T00:00:00.000Z", - }), - ); - const write = vi.fn( - (_input: Parameters[0]) => Effect.void, - ); - const closeIdle = vi.fn( - (_input: Parameters[0]) => Effect.void, - ); - const listeners: Array[0]> = - []; - const subscribe: TerminalManager.TerminalManager["Service"]["subscribe"] = (listener) => - Effect.sync(() => { - listeners.push(listener); - return () => undefined; - }); - const projectId = ProjectId.make("project:setup-runner-v2"); - const project = { - id: projectId, - title: "Project", - workspaceRoot: "/repo", - repositoryIdentity: null, - faviconPath: null, - defaultModelSelection: null, - scripts: [ - { - id: "setup", - name: "Setup", - command: "vp install", - icon: "configure" as const, - runOnWorktreeCreate: true, - }, - { - id: "clean", - name: "Clean", - command: "cargo clean", - icon: "build" as const, - runOnWorktreeCreate: false, - runOnSettle: true, - }, - ], - createdAt: "2026-06-20T00:00:00.000Z", - updatedAt: "2026-06-20T00:00:00.000Z", - deletedAt: null, - }; - const layer = ProjectSetupScriptRunner.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(ProjectService.ProjectService)({ - getById: () => Effect.succeed(Option.some(project)), - }), - Layer.mock(TerminalManager.TerminalManager)({ open, write, subscribe, closeIdle }), - ServerSettings.layerTest(), - NodeCrypto.layer, - ), - ), - ); - - return Effect.gen(function* () { - const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; - const result = yield* runner.runForThread({ - threadId: "thread-1", - projectId, - worktreePath: "/repo-worktree", - }); - assert.deepEqual(result, { - status: "started", - async: true, - scriptId: "setup", - scriptName: "Setup", - scriptCommand: "vp install", - terminalId: "setup-setup", - cwd: "/repo-worktree", - }); - assert.equal(open.mock.calls[0]?.[0].cwd, "/repo-worktree"); - assert.deepEqual(open.mock.calls[0]?.[0].env, { - T3CODE_PROJECT_ROOT: "/repo", - T3CODE_WORKTREE_PATH: "/repo-worktree", - COLORTERM: "", - NO_COLOR: "1", - FORCE_COLOR: "0", - }); - assert.equal(write.mock.calls[0]?.[0].data, " vp install\r"); - const lines: string[] = []; - const observed = yield* runner.runForThread({ - threadId: "thread-1", - projectId, - worktreePath: "/repo-worktree", - observeCompletion: { - onOutputLine: (line) => - Effect.sync(() => { - lines.push(line); - }), - }, - }); - assert.equal(observed.status, "started"); - assert.match(write.mock.calls[1]?.[0].data ?? "", /^ \S/); - const listener = listeners[0]!; - yield* listener({ - type: "output", - threadId: "thread-1", - terminalId: "setup-setup", - data: "Downloading 10%\rDownloading 20%\r\nDone\n", - }); - assert.deepEqual(lines, ["Downloading 10%", "Downloading 20%", "Done"]); - yield* listener({ type: "closed", threadId: "thread-1", terminalId: "setup-setup" }); - - const settle = yield* runner.runForThread({ - threadId: "thread-1", - projectId, - worktreePath: "/repo-worktree", - trigger: "settle", - }); - const settleTerminalId = settle.status === "started" ? settle.terminalId : ""; - assert.match(settleTerminalId, /^settle-clean-/); - assert.equal(write.mock.calls.at(-1)?.[0].data, " cargo clean\r"); - - const observedSettle = yield* runner.runForThread({ - threadId: "thread-1", - projectId, - worktreePath: "/repo-worktree", - trigger: "settle", - observeCompletion: {}, - }); - const observedTerminalId = observedSettle.status === "started" ? observedSettle.terminalId : ""; - assert.notEqual(observedTerminalId, settleTerminalId); - assert.match(write.mock.calls.at(-1)?.[0].data ?? "", /^ \S/); - const token = /__T3_SETUP_DONE___(\w+):/.exec(write.mock.calls.at(-1)?.[0].data ?? "")?.[1]; - const settleListener = listeners.at(-1)!; - const completion = yield* Effect.forkChild( - observedSettle.status === "started" && observedSettle.completion - ? observedSettle.completion - : Effect.die("no completion"), - ); - yield* settleListener({ - type: "output", - threadId: "thread-1", - terminalId: observedTerminalId, - data: `\r\n__T3_SETUP_DONE___${token}:0\r\n`, - }); - yield* Effect.yieldNow; - assert.equal(closeIdle.mock.calls.length, 0); - yield* settleListener({ - type: "output", - threadId: "thread-1", - terminalId: observedTerminalId, - data: "$ ", - }); - assert.deepEqual((yield* Fiber.join(completion)).exitCode, 0); - assert.deepEqual(closeIdle.mock.calls[0]?.[0], { - threadId: "thread-1", - terminalId: observedTerminalId, - }); - }).pipe(Effect.provide(layer)); -}); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 67977c5c7f24..6799be186d1a 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -191,16 +191,16 @@ export const make = Effect.gen(function* () { yield* HostProcess.Environment, ); - const observeTerminalCompletion = (input: { - readonly threadId: string; - readonly terminalId: string; - readonly sentinel: string; - readonly sentinelPattern: RegExp; - /** The shell echoes typed input; lines ending with these are the wrapper, not output. */ - readonly echoedWrapperLines: ReadonlyArray; - readonly onOutputLine: ((line: string) => Effect.Effect) | undefined; - }) => - Effect.gen(function* () { + const observeTerminalCompletion = Effect.fn("ProjectSetupScriptRunner.observeTerminalCompletion")( + function* (input: { + readonly threadId: string; + readonly terminalId: string; + readonly sentinel: string; + readonly sentinelPattern: RegExp; + /** The shell echoes typed input; lines ending with these are the wrapper, not output. */ + readonly echoedWrapperLines: ReadonlyArray; + readonly onOutputLine: ((line: string) => Effect.Effect) | undefined; + }) { const startedAtMs = yield* Clock.currentTimeMillis; const done = yield* Deferred.make(); // The shell redraws its prompt just after the sentinel. Closing before @@ -278,7 +278,8 @@ export const make = Effect.gen(function* () { Effect.ensuring(Effect.sync(() => unsubscribe())), ); return { completion, unsubscribe }; - }); + }, + ); const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( "ProjectSetupScriptRunner.runForThread", diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index fee7c8f76624..6fc57297a40e 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -1,16 +1,10 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; // @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not. import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { SourceControlProviderError } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import { TestClock } from "effect/testing"; import * as ProcessRunner from "../processRunner.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; @@ -27,157 +21,11 @@ const git = (cwd: string, args: ReadonlyArray) => }); }).pipe(Effect.provide(ProcessRunner.layer)); -const layerRepositoryIdentityResolverTest = (options: { - readonly positiveCacheTtl?: Duration.Input; - readonly negativeCacheTtl?: Duration.Input; -}) => - Layer.effect( - RepositoryIdentityResolver.RepositoryIdentityResolver, - RepositoryIdentityResolver.make({ - cacheCapacity: 16, - ...options, - }), - ).pipe(Layer.provide(ProcessRunner.layer)); - it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { - it.effect("refreshes the Git root only when requested", () => { - const calls: Array> = []; - let rootPath = "/repo"; - let remoteUrl = "git@github.com:T3Tools/t3code.git"; - let refinements = 0; - let refinementFails = false; - const layerProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { - run: (input) => - Effect.sync(() => { - calls.push(input.args); - return { - stdout: input.args.includes("rev-parse") - ? `${rootPath}\n` - : `origin\t${remoteUrl} (fetch)\n`, - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - }), - }); - const layerResolver = Layer.effect( - RepositoryIdentityResolver.RepositoryIdentityResolver, - RepositoryIdentityResolver.make({ - refine: (identity) => { - refinements++; - if (refinementFails) - return Effect.fail( - new SourceControlProviderError({ - provider: SourceControlProviderKind.make("forgejo"), - operation: "detectProvider", - cwd: rootPath, - detail: "account unavailable", - }), - ); - return Effect.succeed( - identity.canonicalKey.startsWith("ssh.forge.test/") - ? { - ...identity, - provider: "forgejo", - webUrl: "http://forge.test:3000/git/team/repo", - } - : identity, - ); - }, - }), - ).pipe(Layer.provide(layerProcessRunner)); - - return Effect.gen(function* () { - const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; - const first = yield* resolver.resolve("/repo/packages/web"); - rootPath = "/repo/packages/web"; - // Longer than the one-minute cadence of the background sweeps. - yield* TestClock.adjust(Duration.minutes(10)); - const second = yield* resolver.resolve("/repo/packages/web"); - - expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(second).toEqual(first); - expect(refinements).toBe(1); - expect(calls).toEqual([ - ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], - ["-C", "/repo", "remote", "-v"], - ]); - - const refreshed = yield* resolver.resolve("/repo/packages/web", { refresh: true }); - expect(refreshed?.rootPath).toBe("/repo/packages/web"); - expect(yield* resolver.resolve("/repo/packages/web")).toEqual(refreshed); - expect(calls.slice(2)).toEqual([ - ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], - ["-C", "/repo/packages/web", "remote", "-v"], - ]); - remoteUrl = "git@ssh.forge.test:team/repo.git"; - const forgejo = yield* resolver.resolve(rootPath, { refresh: true }); - expect(forgejo?.webUrl).toBe("http://forge.test:3000/git/team/repo"); - expect(forgejo?.provider).toBe("forgejo"); - expect(forgejo?.canonicalKey).toBe("ssh.forge.test/team/repo"); - expect(forgejo?.locator.remoteUrl).toBe(remoteUrl); - expect(yield* resolver.resolve(rootPath)).toEqual(forgejo); - expect(refinements).toBe(3); - refinementFails = true; - const unavailable = yield* resolver.resolve(rootPath, { refresh: true }); - expect(unavailable?.webUrl).toBeUndefined(); - expect(unavailable?.canonicalKey).toBe("ssh.forge.test/team/repo"); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), layerResolver))); - }); - - it.effect("retries Git root discovery after the negative TTL", () => { - const calls: Array> = []; - let rootAttempts = 0; - const layerProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { - run: (input) => - Effect.sync(() => { - calls.push(input.args); - const rootLookup = input.args.includes("rev-parse"); - const failed = rootLookup && rootAttempts++ === 0; - return { - stdout: rootLookup - ? failed - ? "" - : "/repo\n" - : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", - stderr: failed ? "temporary Git failure" : "", - code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - }), - }); - const layerResolver = Layer.effect( - RepositoryIdentityResolver.RepositoryIdentityResolver, - RepositoryIdentityResolver.make(), - ).pipe(Layer.provide(layerProcessRunner)); - - return Effect.gen(function* () { - const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; - expect(yield* resolver.resolve("/repo/packages/web")).toBeNull(); - expect(yield* resolver.resolve("/repo/packages/web")).toBeNull(); - - yield* TestClock.adjust(Duration.minutes(1)); - const recovered = yield* resolver.resolve("/repo/packages/web"); - expect(recovered?.rootPath).toBe("/repo"); - expect(calls).toEqual([ - ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], - ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], - ["-C", "/repo", "remote", "-v"], - ]); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), layerResolver))); - }); - it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-repository-identity-test-", }); @@ -200,6 +48,31 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity?.provider).toBe("github"); expect(identity?.owner).toBe("T3Tools"); expect(identity?.name).toBe("t3code"); + expect(yield* resolver.resolve(path.join(cwd, ".git"))).toBeNull(); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + ); + + it.effect("resolves the fetch remote of a partial clone", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-partial-clone-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + yield* git(cwd, ["remote", "set-url", "--push", "origin", "git@github.com:T3Tools/push.git"]); + yield* git(cwd, ["config", "remote.origin.promisor", "true"]); + yield* git(cwd, ["config", "remote.origin.partialclonefilter", "blob:none"]); + + const remotes = yield* git(cwd, ["remote", "-v"]); + expect(remotes.stdout).toContain("(fetch) [blob:none]"); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(identity?.locator.remoteUrl).toBe("git@github.com:T3Tools/t3code.git"); + expect(identity?.locator.remoteName).toBe("origin"); }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); @@ -230,6 +103,60 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); + it.effect.each(["bare clone", "dot-bare"] as const)( + "resolves a %s to its Git directory and the same identity as a checkout", + (layout) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const checkout = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-checkout-", + }); + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-bare-", + }); + const bareGitDir = layout === "dot-bare" ? path.join(root, ".bare") : root; + + yield* git(checkout, ["init"]); + yield* git(checkout, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + yield* git(root, ["init", "--bare", bareGitDir]); + if (layout === "dot-bare") { + yield* fileSystem.writeFileString(path.join(root, ".git"), "gitdir: ./.bare\n"); + } + yield* git(root, [ + "--git-dir", + bareGitDir, + "remote", + "add", + "origin", + "https://github.com/T3Tools/t3code.git", + ]); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const checkoutIdentity = yield* resolver.resolve(checkout); + expect(checkoutIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + const resolvedBareGitDir = NodeFS.realpathSync.native(bareGitDir); + const projectRoots = layout === "dot-bare" ? [root, bareGitDir] : [root]; + + for (const projectRoot of projectRoots) { + const identity = yield* resolver.resolve(projectRoot); + const resolvedIdentityRoot = + identity?.rootPath === undefined ? "" : NodeFS.realpathSync.native(identity.rootPath); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe(checkoutIdentity?.canonicalKey); + expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe( + normalizeResolvedPath(resolvedBareGitDir), + ); + } + + yield* git(root, ["--git-dir", bareGitDir, "remote", "remove", "origin"]); + for (const projectRoot of projectRoots) { + expect(yield* resolver.resolve(projectRoot, { refresh: true })).toBeNull(); + } + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + ); + it.effect("returns null for non-git folders and repos without remotes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -334,42 +261,12 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity?.displayName).toBe("T3Tools/t3code"); expect(identity?.origin).toEqual({ canonicalKey: "github.com/julius/t3code-fork", - displayName: "julius/t3code-fork", remoteUrl: "git@github.com:julius/t3code-fork.git", + displayName: "julius/t3code-fork", }); }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); - it.effect("drops credentials from a fork's origin URL but keeps SSH user names", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; - const resolveOrigin = (originUrl: string) => - Effect.gen(function* () { - const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-fork-credentials-test-", - }); - yield* git(cwd, ["init"]); - yield* git(cwd, ["remote", "add", "origin", originUrl]); - yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); - return (yield* resolver.resolve(cwd))?.origin?.remoteUrl; - }); - - expect(yield* resolveOrigin("https://julius:s3cret@github.com/julius/t3code-fork.git")).toBe( - "https://github.com/julius/t3code-fork.git", - ); - expect(yield* resolveOrigin("ssh://git@github.com/julius/t3code-fork.git")).toBe( - "ssh://git@github.com/julius/t3code-fork.git", - ); - expect(yield* resolveOrigin("ssh://git:s3cret@host.test/team/repo?release.git")).toBe( - "ssh://git@host.test/team/repo?release.git", - ); - expect(yield* resolveOrigin("ssh://git:s3cret@host.test/team/link/../repo.git")).toBe( - "ssh://git@host.test/team/link/../repo.git", - ); - }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), - ); - it.effect("uses the last remote path segment as the repository name for nested groups", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -390,86 +287,4 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity?.name).toBe("t3code"); }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); - - it.effect( - "keeps null identities cached across repeated resolves until the negative TTL expires", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-late-remote-test-", - }); - - yield* git(cwd, ["init"]); - - const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; - const initialIdentity = yield* resolver.resolve(cwd); - expect(initialIdentity).toBeNull(); - - yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - - for (const _attempt of [1, 2, 3]) { - const cachedIdentity = yield* resolver.resolve(cwd); - expect(cachedIdentity).toBeNull(); - } - - yield* TestClock.adjust(Duration.millis(120)); - - const refreshedIdentity = yield* resolver.resolve(cwd); - expect(refreshedIdentity).not.toBeNull(); - expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(refreshedIdentity?.name).toBe("t3code"); - }).pipe( - Effect.provide( - Layer.merge( - TestClock.layer(), - layerRepositoryIdentityResolverTest({ - negativeCacheTtl: Duration.millis(50), - positiveCacheTtl: Duration.seconds(1), - }), - ), - ), - ), - ); - - it.effect("refreshes cached identities after the positive TTL when a remote changes", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-remote-change-test-", - }); - - yield* git(cwd, ["init"]); - yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - - const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; - const initialIdentity = yield* resolver.resolve(cwd); - expect(initialIdentity).not.toBeNull(); - expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); - - yield* git(cwd, ["remote", "set-url", "origin", "git@github.com:T3Tools/t3code-next.git"]); - - const cachedIdentity = yield* resolver.resolve(cwd); - expect(cachedIdentity).not.toBeNull(); - expect(cachedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); - - yield* TestClock.adjust(Duration.millis(180)); - - const refreshedIdentity = yield* resolver.resolve(cwd); - expect(refreshedIdentity).not.toBeNull(); - expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code-next"); - expect(refreshedIdentity?.displayName).toBe("T3Tools/t3code-next"); - expect(refreshedIdentity?.name).toBe("t3code-next"); - }).pipe( - Effect.provide( - Layer.merge( - TestClock.layer(), - layerRepositoryIdentityResolverTest({ - negativeCacheTtl: Duration.millis(50), - positiveCacheTtl: Duration.millis(100), - }), - ), - ), - ), - ); }); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index aa3fd3976d09..0ec948b485fc 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -3,10 +3,10 @@ import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server import { detectSourceControlProviderFromGitRemoteUrl, normalizeGitRemoteUrl, - withoutRemoteCredentials, } from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; +import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -59,7 +59,8 @@ function parseRemoteFetchUrls(stdout: string): Map { for (const line of stdout.split("\n")) { const trimmed = line.trim(); if (trimmed.length === 0) continue; - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + // Partial clones append the promisor filter, e.g. `(fetch) [blob:none]`. + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(trimmed); if (!match) continue; const [, remoteName = "", remoteUrl = "", direction = ""] = match; if ( @@ -104,11 +105,7 @@ function buildRepositoryOrigin( const originKey = normalizeGitRemoteUrl(originUrl); if (originKey === canonicalKey) return undefined; const displayName = repositoryPathOf(originKey, originUrl); - return { - canonicalKey: originKey, - ...(displayName ? { displayName } : {}), - remoteUrl: withoutRemoteCredentials(originUrl), - }; + return { canonicalKey: originKey, remoteUrl: originUrl, ...(displayName ? { displayName } : {}) }; } function buildRepositoryIdentity(input: { @@ -141,9 +138,15 @@ function buildRepositoryIdentity(input: { }; } +class RepositoryIdentityCacheKey extends Data.Class<{ + readonly rootPath: string; + readonly isBare: boolean; +}> {} + const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( function* (cwd: string) { const processRunner = yield* ProcessRunner.ProcessRunner; + const env = explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform); // git is a real executable on every platform — no cmd.exe shell mode, which // would split paths containing spaces during cmd's re-tokenization. @@ -151,29 +154,62 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver. .run({ command: "git", args: ["-C", cwd, "rev-parse", "--show-toplevel"], - env: explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform), + env, timeoutBehavior: "timedOutResult", }) .pipe(Effect.option); - if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { + if (topLevelResult._tag === "None" || topLevelResult.value.timedOut) { return null; } + if (topLevelResult.value.code === 0) { + const rootPath = topLevelResult.value.stdout.trim(); + return rootPath.length > 0 + ? new RepositoryIdentityCacheKey({ rootPath, isBare: false }) + : null; + } - const candidate = topLevelResult.value.stdout.trim(); - return candidate.length > 0 ? candidate : null; + // Explicit Git directories preserve safe.bareRepository=explicit for both layouts. + for (const gitDir of [".", ".git"]) { + const bareResult = yield* processRunner + .run({ + command: "git", + args: [ + "-C", + cwd, + `--git-dir=${gitDir}`, + "rev-parse", + "--is-bare-repository", + "--absolute-git-dir", + ], + env, + timeoutBehavior: "timedOutResult", + }) + .pipe(Effect.option); + if (bareResult._tag === "None" || bareResult.value.timedOut) return null; + if (bareResult.value.code !== 0) continue; + + const [isBare, rootPath = ""] = bareResult.value.stdout + .split("\n") + .map((line) => line.trim()); + // A checkout's .git directory contains remotes but is not a project root. + return isBare === "true" && rootPath.length > 0 + ? new RepositoryIdentityCacheKey({ rootPath, isBare: true }) + : null; + } + return null; }, ); const resolveRepositoryIdentityFromCacheKey = Effect.fn( "RepositoryIdentityResolver.resolveFromCacheKey", )(function* ( - cacheKey: string, + cacheKey: RepositoryIdentityCacheKey, ): Effect.fn.Return { const processRunner = yield* ProcessRunner.ProcessRunner; const remoteResult = yield* processRunner .run({ command: "git", - args: ["-C", cacheKey, "remote", "-v"], + args: ["-C", cacheKey.rootPath, ...(cacheKey.isBare ? ["--git-dir=."] : []), "remote", "-v"], env: explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform), timeoutBehavior: "timedOutResult", }) @@ -185,7 +221,11 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn( const remotes = parseRemoteFetchUrls(remoteResult.value.stdout); const remote = pickPrimaryRemote(remotes); return remote - ? buildRepositoryIdentity({ ...remote, originUrl: remotes.get("origin"), rootPath: cacheKey }) + ? buildRepositoryIdentity({ + ...remote, + originUrl: remotes.get("origin"), + rootPath: cacheKey.rootPath, + }) : null; }); @@ -204,7 +244,7 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( onFailure: () => Duration.zero, }); - const repositoryRootCache = yield* Cache.makeWith( + const repositoryRootCache = yield* Cache.makeWith( (cwd) => resolveRepositoryIdentityCacheKey(cwd).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), @@ -212,7 +252,10 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( { capacity: cacheCapacity, timeToLive }, ); - const repositoryIdentityCache = yield* Cache.makeWith( + const repositoryIdentityCache = yield* Cache.makeWith< + RepositoryIdentityCacheKey, + RepositoryIdentity | null + >( (cacheKey) => resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), diff --git a/apps/server/src/project/T3ProjectFileLoader.test.ts b/apps/server/src/project/T3ProjectFileLoader.test.ts index 5b42ea4cd213..5abc78098869 100644 --- a/apps/server/src/project/T3ProjectFileLoader.test.ts +++ b/apps/server/src/project/T3ProjectFileLoader.test.ts @@ -28,40 +28,6 @@ const writeProjectFile = Effect.fn("writeProjectFile")(function* (cwd: string, c it.layer(layerTest)("T3ProjectFileLoader", (it) => { describe("load", () => { - it.effect("loads and decodes a valid t3.json", () => - Effect.gen(function* () { - const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader; - const cwd = yield* makeTempDir; - yield* writeProjectFile( - cwd, - `{ - // JSONC is tolerated - "iconPath": "assets/logo.svg", - "scripts": [{ "name": "Dev", "command": "pnpm dev" }], - }`, - ); - - const loaded = yield* loader.load(cwd); - - expect(Option.isSome(loaded)).toBe(true); - if (Option.isSome(loaded)) { - expect(loaded.value.iconPath).toBe("assets/logo.svg"); - expect(loaded.value.scripts).toEqual([{ name: "Dev", command: "pnpm dev" }]); - } - }), - ); - - it.effect("returns none when t3.json is missing", () => - Effect.gen(function* () { - const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader; - const cwd = yield* makeTempDir; - - const loaded = yield* loader.load(cwd); - - expect(Option.isNone(loaded)).toBe(true); - }), - ); - it.effect("returns none for malformed JSON without failing", () => Effect.gen(function* () { const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader; @@ -73,17 +39,5 @@ it.layer(layerTest)("T3ProjectFileLoader", (it) => { expect(Option.isNone(loaded)).toBe(true); }), ); - - it.effect("returns none for schema-invalid files without failing", () => - Effect.gen(function* () { - const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader; - const cwd = yield* makeTempDir; - yield* writeProjectFile(cwd, '{ "scripts": [{ "name": "Dev" }] }'); - - const loaded = yield* loader.load(cwd); - - expect(Option.isNone(loaded)).toBe(true); - }), - ); }); }); diff --git a/apps/server/src/project/WorktreeSetupTracker.test.ts b/apps/server/src/project/WorktreeSetupTracker.test.ts index e96a1815f185..ed3fcc027c72 100644 --- a/apps/server/src/project/WorktreeSetupTracker.test.ts +++ b/apps/server/src/project/WorktreeSetupTracker.test.ts @@ -1,13 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; -import { ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; -import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as WorktreeSetupTracker from "./WorktreeSetupTracker.ts"; @@ -76,34 +73,6 @@ describe("WorktreeSetupTracker", () => { }), ); - it.effect("stream never steps back behind the snapshot it started from", () => - Effect.gen(function* () { - const tracker = yield* WorktreeSetupTracker.make; - yield* tracker.begin({ - threadId, - branch: null, - baseRef: null, - stages: ["agent"], - fiber: null, - }); - yield* tracker.stageStatus(threadId, "agent", "running"); - yield* tracker.stageStatus(threadId, "agent", "done"); - - const collected = yield* tracker.stream(threadId).pipe( - Stream.takeUntil((snapshot) => snapshot?.phase === "done"), - Stream.runCollect, - Effect.forkChild, - ); - yield* Effect.yieldNow; - yield* tracker.finish(threadId, "done"); - - const snapshots = yield* Fiber.join(collected); - expect(snapshots.length).toBeGreaterThan(0); - expect(snapshots.every((snapshot) => (snapshot?.sequence ?? -1) >= 2)).toBe(true); - expect(snapshots.at(-1)?.phase).toBe("done"); - }), - ); - it.effect("a new setup on the same thread keeps sequences increasing", () => Effect.gen(function* () { const tracker = yield* WorktreeSetupTracker.make; @@ -138,24 +107,6 @@ describe("WorktreeSetupTracker", () => { }), ); - it.effect("finished setups are dropped after the retention window", () => - Effect.gen(function* () { - const tracker = yield* WorktreeSetupTracker.make; - yield* tracker.begin({ - threadId, - branch: null, - baseRef: null, - stages: ["agent"], - fiber: null, - }); - yield* tracker.finish(threadId, "done"); - expect((yield* tracker.get(threadId))?.phase).toBe("done"); - - yield* TestClock.adjust(Duration.seconds(31)); - expect(yield* tracker.get(threadId)).toBeNull(); - }), - ); - it.effect("cancel interrupts the bootstrap fiber and reports whether one was running", () => Effect.gen(function* () { const tracker = yield* WorktreeSetupTracker.make; @@ -189,30 +140,4 @@ describe("WorktreeSetupTracker", () => { yield* Fiber.interrupt(fiber); }), ); - - it.effect("clamps free text to the contract limits before publishing", () => - Effect.gen(function* () { - const tracker = yield* WorktreeSetupTracker.make; - yield* tracker.begin({ - threadId, - branch: null, - baseRef: null, - stages: ["checkout", "setup-script"], - fiber: null, - }); - const long = "x".repeat(2_000); - - yield* tracker.stageStatus(threadId, "checkout", "done", long); - yield* tracker.stage(threadId, "setup-script", { detail: long }); - yield* tracker.appendTail(threadId, "setup-script", long); - yield* tracker.finish(threadId, "failed", long); - - const snapshot = yield* tracker.get(threadId); - expect(snapshot?.stages[0]?.detail?.length).toBe(200); - expect(snapshot?.stages[1]?.detail?.length).toBe(200); - expect(snapshot?.stages[1]?.tail[0]?.length).toBe(400); - expect(snapshot?.error?.length).toBe(1000); - expect(Schema.is(WorktreeSetupSnapshot)(snapshot)).toBe(true); - }), - ); }); diff --git a/apps/server/src/project/WorktreeSetupTracker.ts b/apps/server/src/project/WorktreeSetupTracker.ts index 43639afe4515..0bdf7af4d2b0 100644 --- a/apps/server/src/project/WorktreeSetupTracker.ts +++ b/apps/server/src/project/WorktreeSetupTracker.ts @@ -147,8 +147,8 @@ export const make = Effect.gen(function* () { Effect.tap(() => Effect.sync(() => lastSequenceByThread.delete(threadId))), ); - const begin: WorktreeSetupTracker["Service"]["begin"] = (input) => - Effect.gen(function* () { + const begin: WorktreeSetupTracker["Service"]["begin"] = Effect.fn("WorktreeSetupTracker.begin")( + function* (input) { yield* clearRetention(input.threadId); const startedAt = yield* nowIso; const ordered = WORKTREE_SETUP_STAGE_ORDER.filter((id) => input.stages.includes(id)); @@ -172,7 +172,8 @@ export const make = Effect.gen(function* () { return next; }); yield* publish(input.threadId, snapshot); - }); + }, + ); const update: WorktreeSetupTracker["Service"]["update"] = (threadId, mutate) => modify(threadId, (tracked) => ({ ...tracked, snapshot: mutate(tracked.snapshot) })).pipe( @@ -235,44 +236,45 @@ export const make = Effect.gen(function* () { ), })); - const finish: WorktreeSetupTracker["Service"]["finish"] = (threadId, phase, error) => - Effect.gen(function* () { - const endedAt = yield* nowIso; - const snapshot = yield* modify(threadId, (tracked) => ({ - fiber: null, - snapshot: { - ...tracked.snapshot, - phase, - endedAt, - error: - error === undefined || error === null - ? null - : clampText(error, WORKTREE_SETUP_ERROR_MAX_LENGTH), - stages: tracked.snapshot.stages.map((entry) => - entry.status === "running" - ? { - ...entry, - status: phase === "done" ? "done" : phase === "cancelled" ? "skipped" : "failed", - endedAt, - } - : entry, - ), - }, - })); - if (!snapshot) return null; - yield* clearRetention(threadId); - const fiber = yield* remove(threadId).pipe( - Effect.delay(FINISHED_RETENTION), - Effect.ensuring( - Effect.sync(() => { - if (retentionFibers.get(threadId) === fiber) retentionFibers.delete(threadId); - }), + const finish: WorktreeSetupTracker["Service"]["finish"] = Effect.fn( + "WorktreeSetupTracker.finish", + )(function* (threadId, phase, error) { + const endedAt = yield* nowIso; + const snapshot = yield* modify(threadId, (tracked) => ({ + fiber: null, + snapshot: { + ...tracked.snapshot, + phase, + endedAt, + error: + error === undefined || error === null + ? null + : clampText(error, WORKTREE_SETUP_ERROR_MAX_LENGTH), + stages: tracked.snapshot.stages.map((entry) => + entry.status === "running" + ? { + ...entry, + status: phase === "done" ? "done" : phase === "cancelled" ? "skipped" : "failed", + endedAt, + } + : entry, ), - Effect.forkDetach, - ); - retentionFibers.set(threadId, fiber); - return snapshot; - }); + }, + })); + if (!snapshot) return null; + yield* clearRetention(threadId); + const fiber = yield* remove(threadId).pipe( + Effect.delay(FINISHED_RETENTION), + Effect.ensuring( + Effect.sync(() => { + if (retentionFibers.get(threadId) === fiber) retentionFibers.delete(threadId); + }), + ), + Effect.forkDetach, + ); + retentionFibers.set(threadId, fiber); + return snapshot; + }); const markUncancellable: WorktreeSetupTracker["Service"]["markUncancellable"] = (threadId) => Ref.update(setups, (current) => { @@ -283,16 +285,17 @@ export const make = Effect.gen(function* () { return next; }); - const cancel: WorktreeSetupTracker["Service"]["cancel"] = (threadId) => - Effect.gen(function* () { - const current = yield* Ref.get(setups); - const tracked = current.get(threadId); - if (!tracked || tracked.snapshot.phase !== "running" || !tracked.fiber) { - return false; - } - yield* Fiber.interrupt(tracked.fiber); - return true; - }); + const cancel: WorktreeSetupTracker["Service"]["cancel"] = Effect.fn( + "WorktreeSetupTracker.cancel", + )(function* (threadId) { + const current = yield* Ref.get(setups); + const tracked = current.get(threadId); + if (!tracked || tracked.snapshot.phase !== "running" || !tracked.fiber) { + return false; + } + yield* Fiber.interrupt(tracked.fiber); + return true; + }); const get: WorktreeSetupTracker["Service"]["get"] = (threadId) => Ref.get(setups).pipe(Effect.map((current) => current.get(threadId)?.snapshot ?? null)); diff --git a/apps/server/src/project/http.test.ts b/apps/server/src/project/http.test.ts deleted file mode 100644 index ba20c5fbbc9f..000000000000 --- a/apps/server/src/project/http.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { ProjectId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; - -import { - ProjectConflictError, - ProjectNotEmptyError, - ProjectNotFoundError, - ProjectOperationError, -} from "./ProjectService.ts"; -import { ServerRuntimeStartupError } from "../serverRuntimeStartup.ts"; -import { failProjectMutation } from "./http.ts"; - -const projectId = ProjectId.make("project:http-mutation"); - -it.effect.each([ - new ProjectNotFoundError({ projectId }), - new ProjectNotEmptyError({ projectId }), - new ProjectConflictError({ - projectId, - workspaceRoot: "/workspace/project", - conflictingProjectId: ProjectId.make("project:http-mutation-conflict"), - }), -])("maps expected project mutation failures to invalid requests", (cause) => - Effect.gen(function* () { - const error = yield* failProjectMutation(cause).pipe(Effect.flip); - - assert.equal(error._tag, "EnvironmentRequestInvalidError"); - assert.equal(error.code, "invalid_request"); - assert.equal(error.reason, "invalid_command"); - }), -); - -it.effect.each([ - new ProjectOperationError({ - operation: "dispatch-project-command", - projectId, - cause: "database unavailable", - }), - new ServerRuntimeStartupError({ - mode: "web", - host: null, - port: 0, - cause: "startup unavailable", - }), -])("keeps operational and startup failures internal", (cause) => - Effect.gen(function* () { - const error = yield* failProjectMutation(cause).pipe(Effect.flip); - - assert.equal(error._tag, "EnvironmentInternalError"); - assert.equal(error.code, "internal_error"); - assert.equal(error.reason, "project_mutation_failed"); - }), -); diff --git a/apps/server/src/project/http.ts b/apps/server/src/project/http.ts index e1a04655d835..825e6c1cc8f4 100644 --- a/apps/server/src/project/http.ts +++ b/apps/server/src/project/http.ts @@ -17,7 +17,7 @@ import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; import * as ProjectService from "./ProjectService.ts"; import { projectMutationOperation } from "./ProjectMutation.ts"; -export const failProjectMutation = Effect.fn("environment.projects.failMutation")(function* ( +const failProjectMutation = Effect.fn("environment.projects.failMutation")(function* ( cause: ProjectService.ProjectServiceError | ServerRuntimeStartup.ServerRuntimeStartupError, ) { if ( diff --git a/apps/server/src/project/threadWorkspaceRoot.ts b/apps/server/src/project/threadWorkspaceRoot.ts new file mode 100644 index 000000000000..7ad8a3da9930 --- /dev/null +++ b/apps/server/src/project/threadWorkspaceRoot.ts @@ -0,0 +1,19 @@ +import type { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "./ProjectService.ts"; + +/** The directory a thread works in, or undefined when the thread or its project is gone. */ +export const threadWorkspaceRoot = Effect.fn("threadWorkspaceRoot")( + function* (threadId: ThreadId) { + const threads = yield* ThreadManagementService.ThreadManagementService; + const projects = yield* ProjectService.ProjectService; + const shell = yield* threads.getThreadShell(threadId); + if (shell === null) return undefined; + if (shell.worktreePath) return shell.worktreePath; + return Option.getOrUndefined(yield* projects.getById(shell.projectId))?.workspaceRoot; + }, + Effect.orElseSucceed(() => undefined), +); diff --git a/apps/server/src/provider/AcpRegistryProbe.test.ts b/apps/server/src/provider/AcpRegistryProbe.test.ts index 7b69ca58baaa..bc7c14d3b3c8 100644 --- a/apps/server/src/provider/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/AcpRegistryProbe.test.ts @@ -6,11 +6,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderInstanceId } from "@t3tools/contracts"; import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings"; import { describe, expect, it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; @@ -21,7 +18,6 @@ import { logoutAcpRegistry, normalizeAcpRegistryAuthMethods, normalizeAcpRegistryCommands, - probeAcpRegistryConfiguration, } from "@t3tools/provider-acp-registry/testing"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; @@ -141,61 +137,6 @@ describe("ACP Registry probe", () => { expect(methods[0]).not.toHaveProperty("command"); }); - it("omits overlong opaque auth method and environment variable ids", () => { - const methods = normalizeAcpRegistryAuthMethods([ - { id: "x".repeat(129), name: "Invalid" }, - { - id: "api-key", - name: "API key", - type: "env_var", - vars: [ - { name: "y".repeat(129), label: "Invalid" }, - { name: "OPENAI_API_KEY", label: "OpenAI API key" }, - ], - }, - ]); - - expect(methods).toEqual([ - { - id: "api-key", - name: "API key", - description: null, - type: "env_var", - envVarNames: ["OPENAI_API_KEY"], - }, - ]); - }); - - it("falls back to model-category configuration options", () => { - const result = acpRegistryProbeResult(instanceId, { - sessionId: "probe-session", - initializeResult: { protocolVersion: 1 }, - sessionSetupResult: { - sessionId: "probe-session", - configOptions: [ - { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: "sonnet", - options: [ - { value: "sonnet", name: "Sonnet", description: "Balanced" }, - { value: "haiku", name: "Haiku" }, - ], - }, - ], - }, - modelConfigId: "model", - } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); - - expect(result.models).toEqual([ - { id: "sonnet", name: "Sonnet", description: "Balanced" }, - { id: "haiku", name: "Haiku", description: null }, - ]); - expect(result.currentModelId).toBe("sonnet"); - }); - it("omits overlong opaque model ids instead of publishing mutated ids", () => { const result = acpRegistryProbeResult(instanceId, { sessionId: "probe-session", @@ -292,32 +233,42 @@ describe("ACP Registry probe", () => { ]); }); - it("omits a current model that falls outside the bounded model catalog", () => { - const result = acpRegistryProbeResult(instanceId, { - sessionId: "probe-session", - initializeResult: { protocolVersion: 1 }, - sessionSetupResult: { + it.each(["config option", "session model state"] as const)( + "retains the current model beyond the catalog cap from %s", + (source) => { + const models = Array.from({ length: 257 }, (_, index) => ({ + modelId: `model-${index}`, + name: `Model ${index}`, + })); + const result = acpRegistryProbeResult(instanceId, { sessionId: "probe-session", - configOptions: [ - { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: "model-256", - options: Array.from({ length: 257 }, (_, index) => ({ - value: `model-${index}`, - name: `Model ${index}`, - })), - }, - ], - }, - modelConfigId: "model", - } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); - - expect(result.models).toHaveLength(256); - expect(result.currentModelId).toBeNull(); - }); + initializeResult: { protocolVersion: 1 }, + sessionSetupResult: { + sessionId: "probe-session", + ...(source === "config option" + ? { + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "model-256", + options: models.map(({ modelId, name }) => ({ value: modelId, name })), + }, + ], + } + : { models: { currentModelId: "model-256", availableModels: models } }), + }, + modelConfigId: source === "config option" ? "model" : undefined, + } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); + + expect(result.models).toHaveLength(256); + expect(result.models[0]?.id).toBe("model-0"); + expect(result.models.at(-1)?.id).toBe("model-256"); + expect(result.currentModelId).toBe("model-256"); + }, + ); it("bounds and de-duplicates advertised commands", () => { const commands = normalizeAcpRegistryCommands([ @@ -367,97 +318,6 @@ describe("ACP Registry probe", () => { }); }); - it.effect("captures commands advertised just after session creation", () => - Effect.gen(function* () { - const result = yield* probeAcpRegistryConfiguration({ - instanceId, - settings: decodeSettings({ agentId: "mock-agent" }), - cwd: process.cwd(), - environment: process.env, - }); - - expect(result.slashCommands).toEqual([ - { - name: "review", - description: "Review the current changes", - input: { hint: "focus" }, - }, - ]); - expect(result.skills).toEqual([ - { - name: "workspace-skill", - description: "Run the workspace skill", - path: "acp://skill/workspace-skill", - scope: "agent", - enabled: true, - }, - ]); - }).pipe( - Effect.provideService( - AcpRegistrySupport.AcpRegistryCatalog, - AcpRegistrySupport.AcpRegistryCatalog.of({ - search: () => Effect.die("unused search"), - prepare: () => Effect.die("unused prepare"), - inspect: () => Effect.die("unused inspect"), - uninstallManagedBinary: () => Effect.die("unused uninstall"), - resolve: () => - Effect.succeed({ - agent: { - id: "mock-agent", - name: "Mock Agent", - version: "1.0.0", - description: "ACP probe test agent", - distribution: { npx: { package: "mock-agent@1.0.0" } }, - }, - distribution: "npx", - spawn: { - command: "node", - args: [mockAgentPath], - env: { - ...process.env, - T3_ACP_COMMAND_ADVERTISEMENT_DELAY_MS: "25", - }, - }, - }), - }), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), - ); - - it.effect("includes package resolution in the probe timeout", () => - Effect.gen(function* () { - const resolveStarted = yield* Deferred.make(); - const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({ - search: () => Effect.die("unused search"), - prepare: () => Effect.die("unused prepare"), - inspect: () => Effect.die("unused inspect"), - uninstallManagedBinary: () => Effect.die("unused uninstall"), - resolve: () => - Deferred.succeed(resolveStarted, undefined).pipe(Effect.andThen(Effect.never)), - }); - const probe = yield* probeAcpRegistryConfiguration({ - instanceId, - settings: decodeSettings({ agentId: "slow-agent" }), - cwd: process.cwd(), - environment: process.env, - }).pipe( - Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), - Effect.flip, - Effect.forkChild({ startImmediately: true }), - ); - - yield* Deferred.await(resolveStarted); - yield* TestClock.adjust("60 seconds"); - - expect(yield* Fiber.join(probe)).toMatchObject({ - reason: "probe_failed", - message: expect.stringContaining("within 60 seconds"), - }); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - it.effect("lists native sessions and logs out through generic ACP lifecycle methods", () => { const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({ search: () => Effect.die("unused search"), @@ -514,50 +374,6 @@ describe("ACP Registry probe", () => { ); }); - it.effect("preserves the ACP method error for unsupported session management", () => { - const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({ - search: () => Effect.die("unused search"), - prepare: () => Effect.die("unused prepare"), - inspect: () => Effect.die("unused inspect"), - uninstallManagedBinary: () => Effect.die("unused uninstall"), - resolve: () => - Effect.succeed({ - agent: { - id: "mock-agent", - name: "Mock Agent", - version: "1.0.0", - description: "ACP unsupported management test agent", - distribution: { npx: { package: "mock-agent@1.0.0" } }, - }, - distribution: "npx" as const, - spawn: { - command: "node", - args: [mockAgentPath], - env: { ...process.env, T3_ACP_OMIT_SESSION_LIST_HANDLER: "1" }, - }, - }), - }); - - return Effect.gen(function* () { - const failure = yield* listAcpRegistrySessions({ - instanceId, - settings: decodeSettings({ agentId: "mock-agent" }), - cwd: process.cwd(), - environment: process.env, - }).pipe(Effect.flip); - - expect(failure).toMatchObject({ - reason: "session_list_unsupported", - message: "The ACP agent does not advertise session listing.", - cause: { _tag: "AcpRequestError", code: -32601 }, - }); - }).pipe( - Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), - Effect.provide(NodeServices.layer), - Effect.scoped, - ); - }); - it("distinguishes authentication failures from generic probe failures", () => { const advertised = normalizeAcpRegistryAuthMethods([ { id: "grok-login", name: "Log in with Grok" }, diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts index 02e8b691a133..dfcfb2fecdc8 100644 --- a/apps/server/src/provider/AntigravityAuth.test.ts +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -8,7 +8,6 @@ import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as AcpErrors from "effect-acp/errors"; import type * as AcpSchema from "effect-acp/compat"; @@ -143,21 +142,6 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( }); it.layer(NodeServices.layer)("AntigravityAuth", (it) => { - it.effect("accepts the same authorization URL from stderr and stdout", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ - authorizationUrls: [authorizationUrl, authorizationUrl], - }); - yield* harness.auth.controller.start(owner); - const waiting = yield* phase(harness.auth, "waiting"); - assert.equal(waiting.authorizationUrl, authorizationUrl); - - yield* Deferred.succeed(harness.authenticated, undefined); - yield* Deferred.succeed(harness.discovered, undefined); - yield* phase(harness.auth, "succeeded"); - }), - ); - it.effect("accepts a delayed duplicate after callback completion starts", () => Effect.gen(function* () { const harness = yield* makeHarness(); @@ -352,24 +336,6 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => { }), ); - it.effect("expires the flow at the official deadline and removes its URL", () => - Effect.gen(function* () { - const harness = yield* makeHarness(); - const state = yield* harness.auth.controller.start(owner); - yield* phase(harness.auth, "waiting"); - yield* TestClock.adjust("300 seconds"); - const failed = yield* phase(harness.auth, "failed"); - assert.include(failed.message ?? "", "expired"); - assert.isNull(failed.authorizationUrl); - yield* Deferred.await(harness.closed); - const late = yield* harness.auth.controller - .complete(owner, { flowId: state.flowId!, callbackUrl }) - .pipe(Effect.exit); - assert.isTrue(Exit.isFailure(late)); - assert.equal(harness.forwarded(), 0); - }), - ); - it.effect("survives subscriber disconnect and does not replace a competing client's flow", () => Effect.gen(function* () { const harness = yield* makeHarness(); @@ -422,45 +388,6 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => { }), ); - it.effect("signs out after a slow packaged runtime starts", () => - Effect.gen(function* () { - const entered = yield* Deferred.make(); - const initialized = yield* Deferred.make(); - const harness = yield* makeHarness({ - beforeInitialize: Deferred.succeed(entered, undefined).pipe( - Effect.andThen(Deferred.await(initialized)), - ), - }); - const logout = yield* harness.auth.controller.logout(Effect.void).pipe(Effect.forkScoped); - yield* Deferred.await(entered); - yield* TestClock.adjust("47 seconds"); - yield* Deferred.succeed(initialized, undefined); - assert.equal((yield* Fiber.join(logout)).phase, "idle"); - assert.include(harness.events, "logout"); - assert.deepEqual(harness.catalog(), []); - yield* Deferred.await(harness.closed); - }), - ); - - it.effect("closes a stalled sign-out process without clearing its account catalog", () => - Effect.gen(function* () { - const entered = yield* Deferred.make(); - const harness = yield* makeHarness({ - beforeInitialize: Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)), - }); - const logout = yield* harness.auth.controller - .logout(Effect.void) - .pipe(Effect.exit, Effect.forkScoped); - yield* Deferred.await(entered); - yield* TestClock.adjust("90 seconds"); - assert.isTrue(Exit.isFailure(yield* Fiber.join(logout))); - yield* Deferred.await(harness.closed); - assert.notInclude(harness.events, "logout"); - assert.deepEqual(harness.catalog(), ["previous-account-model"]); - yield* harness.auth.withProcess(Effect.void, Effect.void).pipe(Effect.scoped); - }), - ); - it.effect( "sign-out interrupts startup without interrupting its caller after startup returns", () => diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index ea1c8f7448cc..c97aa949aab8 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -261,8 +261,8 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < ), ); - const runSignIn = (flow: AuthFlow, stopSessions: Effect.Effect) => - Effect.gen(function* () { + const runSignIn = Effect.fn("AntigravityAuth.runSignIn")( + function* (flow: AuthFlow, stopSessions: Effect.Effect) { yield* stopSessions.pipe(Effect.ensuring(stopOwnedProcesses)); const runtime = yield* options.makeRuntime({ onAuthorizationUrl: (url) => receiveAuthorizationUrl(flow, url), @@ -281,16 +281,19 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < }), ); yield* options.onAuthenticated(started, runtime); - }).pipe( - Effect.scoped, - Effect.timeoutOrElse({ - duration: AUTH_TIMEOUT_MS, - orElse: () => - Effect.fail(setupError("start", "Google sign-in expired. Start sign-in again.")), - }), - Effect.exit, - Effect.flatMap((result) => finishFlow(flow, result)), - ); + }, + (effect, flow, stopSessions) => + effect.pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: AUTH_TIMEOUT_MS, + orElse: () => + Effect.fail(setupError("start", "Google sign-in expired. Start sign-in again.")), + }), + Effect.exit, + Effect.flatMap((result) => finishFlow(flow, result)), + ), + ); const stopFlow = (flow: AuthFlow, phase: "cancelled" | "failed", message: string) => Effect.uninterruptible( @@ -322,18 +325,21 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < }), ); - const requireFlow = (ownerSessionId: string, flowId: string, name: string) => - Effect.gen(function* () { - const flow = activeFlow; - if (!flow || flow.id !== flowId || flow.ownerSessionId !== ownerSessionId) { - return yield* setupError(name, "This sign-in is no longer active in this client."); - } - const now = yield* Clock.currentTimeMillis; - if (now >= flow.expiresAtMillis) { - return yield* setupError(name, "Google sign-in expired. Start sign-in again."); - } - return flow; - }); + const requireFlow = Effect.fn("AntigravityAuth.requireFlow")(function* ( + ownerSessionId: string, + flowId: string, + name: string, + ) { + const flow = activeFlow; + if (!flow || flow.id !== flowId || flow.ownerSessionId !== ownerSessionId) { + return yield* setupError(name, "This sign-in is no longer active in this client."); + } + const now = yield* Clock.currentTimeMillis; + if (now >= flow.expiresAtMillis) { + return yield* setupError(name, "Google sign-in expired. Start sign-in again."); + } + return flow; + }); const controller: ProviderAuthController = { start: (ownerSessionId, stopSessions = Effect.void) => diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index 2817b2159429..e7bcbe7553f6 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -11,23 +11,14 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; -import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as AntigravityInstallation from "./AntigravityInstallation.ts"; -import { ANTIGRAVITY_AUTH_BROWSER_MARKER } from "./antigravityAuthSupport.ts"; -import { - resolveAntigravityReleaseAsset, - type AntigravityReleaseAsset, -} from "./antigravityRelease.ts"; - -import antigravityInitialize from "../../../../packages/effect-acp/test/fixtures/antigravity-initialize.json" with { type: "json" }; +import { type AntigravityReleaseAsset } from "./antigravityRelease.ts"; const serverContents = "antigravity runtime\n"; const harnessContents = "local harness\n"; @@ -307,252 +298,6 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { }), ); - it.effect.each( - [ - { - name: "the expected release with protocol 1", - protocolVersion: 1, - agentName: "antigravity-acp", - version: "fixture-new", - valid: true, - }, - { - name: "the expected release with protocol 2 and legacy fields", - protocolVersion: 2, - agentName: "antigravity-acp", - version: "fixture-new", - valid: true, - }, - { - name: "an unsupported protocol version", - protocolVersion: 3, - agentName: "antigravity-acp", - version: "fixture-new", - valid: false, - }, - { name: "a different agent", agentName: "other-agent", version: "fixture-new", valid: false }, - { - name: "a different version", - agentName: "antigravity-acp", - version: "other-version", - valid: false, - }, - { - name: "the Windows ARM release with locked child processes", - platform: "win32" as const, - arch: "arm64" as const, - agentName: "antigravity-acp", - version: "fixture-new", - valid: true, - }, - { - name: "an invalid Windows ARM release with locked child processes", - platform: "win32" as const, - arch: "arm64" as const, - agentName: "antigravity-acp", - version: "other-version", - valid: false, - }, - { - name: "a cancelled Windows ARM verification with locked child processes", - platform: "win32" as const, - arch: "arm64" as const, - agentName: "antigravity-acp", - version: "fixture-new", - cancel: true, - valid: false, - }, - ].flatMap((testCase) => { - const cleanupErrorTags = - testCase.platform === "win32" - ? (["Unknown", "PermissionDenied", "Busy"] as const) - : (["Unknown"] as const); - return cleanupErrorTags.map((cleanupErrorTag) => ({ - ...testCase, - cleanupErrorTag, - name: `${cleanupErrorTag}: ${testCase.name}`, - })); - }), - )("validates $name with initialize only and removes the disposable profile", (testCase) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const platform = testCase.platform ?? hostPlatform; - const encoder = new TextEncoder(); - const decodeRequest = Schema.decodeUnknownEffect( - Schema.fromJsonString( - Schema.Struct({ - id: Schema.Union([Schema.String, Schema.Number]), - method: Schema.String, - }), - ), - ); - const methods: string[] = []; - const profiles = new Set(); - const cleanupRetryStarted = yield* Deferred.make(); - const initializationStarted = yield* Deferred.make(); - let childProcessesRunning = false; - let profileRemovalAttempts = 0; - let closedRuntimes = 0; - const spawner = ChildProcessSpawner.make( - Effect.fn("test.spawnAntigravityValidator")(function* (command) { - if (command._tag !== "StandardCommand") { - return yield* Effect.die("Expected one validation process."); - } - const profile = command.options.env?.GEMINI_HOME; - if (!profile) return yield* Effect.die("Expected a disposable validation profile."); - profiles.add(profile); - const helper = command.args[0] === "-e"; - if (!helper) childProcessesRunning = true; - if (!helper) { - if (platform === "win32") { - expect(command.options.env?.TEMP).toBe(profile); - expect(command.options.env?.TMP).toBe(profile); - } else { - expect(command.options.env?.TMPDIR).toBe(profile); - } - } - const output = yield* Queue.unbounded(); - const exited = yield* Deferred.make(); - const terminate = Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)).pipe( - Effect.asVoid, - ); - yield* Effect.addFinalizer(() => - terminate.pipe( - Effect.andThen(Queue.shutdown(output)), - Effect.andThen( - Effect.sync(() => { - if (!helper) closedRuntimes += 1; - }), - ), - ), - ); - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(helper ? 1 : 2), - exitCode: helper - ? Effect.succeed(ChildProcessSpawner.ExitCode(0)) - : Deferred.await(exited), - isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)), - kill: () => - Effect.sync(() => { - if (!helper) childProcessesRunning = false; - }).pipe(Effect.andThen(terminate)), - unref: Effect.succeed(Effect.void), - stdin: Sink.forEach((bytes: Uint8Array) => - Effect.gen(function* () { - const request = yield* decodeRequest(new TextDecoder().decode(bytes)).pipe( - Effect.orDie, - ); - methods.push(request.method); - if (testCase.cancel) { - yield* Deferred.succeed(initializationStarted, undefined); - return; - } - yield* Queue.offer( - output, - encoder.encode( - `${encodeJsonString({ - jsonrpc: "2.0", - id: request.id, - ...(request.method === "initialize" - ? { - result: { - ...antigravityInitialize, - protocolVersion: testCase.protocolVersion ?? 2, - agentInfo: { name: testCase.agentName, version: testCase.version }, - }, - } - : { - error: { - code: -32601, - message: "Validation must not sign in or create sessions.", - }, - }), - })}\n`, - ), - ); - }), - ), - stdout: helper ? Stream.empty : Stream.fromQueue(output), - stderr: helper - ? Stream.make( - encoder.encode( - `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeJsonString(command.args.at(-1))}\n`, - ), - ) - : Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ); - const { installation, stagingReleased } = yield* makeHarness({ - previous: true, - useDefaultValidation: true, - platform, - ...(testCase.arch ? { arch: testCase.arch } : {}), - archive: - platform === "win32" ? Buffer.from(zipFixtures.windows, "base64") : completeArchive, - fileSystem: FileSystem.FileSystem.of({ - ...fs, - remove: (target, options) => - Effect.suspend(() => { - if (platform === "win32" && profiles.has(target)) { - profileRemovalAttempts += 1; - expect(childProcessesRunning).toBe(false); - // Even after the tree exits, Windows can briefly retain a DLL handle. - if (profileRemovalAttempts === 1) { - return Deferred.succeed(cleanupRetryStarted, undefined).pipe( - Effect.andThen( - Effect.fail( - PlatformError.systemError({ - _tag: testCase.cleanupErrorTag, - module: "FileSystem", - method: "remove", - description: "Unpacked DLL is in use", - }), - ), - ), - ); - } - } - return fs.remove(target, options); - }), - }), - }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); - const started = yield* installation.start; - const cancellation = testCase.cancel - ? yield* Deferred.await(initializationStarted).pipe( - Effect.andThen(installation.cancel(started.operationId!)), - Effect.forkChild, - ) - : undefined; - if (platform === "win32") { - yield* Effect.raceFirst( - Deferred.await(cleanupRetryStarted).pipe(Effect.andThen(TestClock.adjust("100 millis"))), - terminalState(installation), - ); - } - if (cancellation) yield* Fiber.join(cancellation); - expect((yield* terminalState(installation)).phase).toBe( - testCase.cancel ? "cancelled" : testCase.valid ? "succeeded" : "failed", - ); - yield* Deferred.await(stagingReleased); - expect(methods).toEqual(["initialize"]); - expect(closedRuntimes).toBe(1); - expect(profiles.size).toBe(1); - if (platform === "win32") expect(profileRemovalAttempts).toBe(2); - for (const profile of profiles) { - expect(yield* fs.exists(profile)).toBe(false); - } - if (testCase.valid) { - expect((yield* installation.resolve()).version).toBe("fixture-new"); - } else { - yield* expectPreviousRelease(installation); - } - }), - ); - it.effect("accepts an encoded Content-Length when the body is compressed", () => Effect.gen(function* () { // dl.google.com gzips the archive and reports the encoded size. The decoded @@ -1051,28 +796,4 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { expect(requests).toEqual([]); }), ); - - it("resolves all supported platform release assets including Intel Mac", () => { - const supportedPlatforms: Array<{ readonly platform: NodeJS.Platform; readonly arch: string }> = - [ - { platform: "darwin", arch: "arm64" }, - { platform: "darwin", arch: "x64" }, - { platform: "linux", arch: "x64" }, - { platform: "linux", arch: "arm64" }, - { platform: "win32", arch: "x64" }, - { platform: "win32", arch: "arm64" }, - ]; - - for (const { platform, arch } of supportedPlatforms) { - const asset = resolveAntigravityReleaseAsset(platform, arch); - expect(asset).not.toBeNull(); - expect(asset?.version).toBe("1.3.0"); - expect(asset?.url).toContain("1.3.0"); - expect(asset?.archiveBytes).toBeGreaterThan(0); - expect(asset?.executable.bytes).toBeGreaterThan(0); - expect(asset?.harness.bytes).toBeGreaterThan(0); - } - - expect(resolveAntigravityReleaseAsset("freebsd", "x64")).toBeNull(); - }); }); diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index b73d26a90653..3b7cb6f1e18a 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -24,6 +24,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as NodeCrypto from "node:crypto"; import * as NodeFSP from "node:fs/promises"; +import * as EffectAcpErrors from "effect-acp/errors"; import * as ServerConfig from "../config.ts"; import { openZipArchive } from "../zipArchive.ts"; @@ -69,6 +70,7 @@ export class AntigravityInstallationError extends Schema.TaggedError { + if (isInstallationError(cause)) return cause; + const detail = + isAcpProcessExitedError(cause) && + /\b(?:AF_INET6|enforce_kernel_ipv6_support)\b/.test(cause.stderr ?? "") + ? "Antigravity requires IPv6 socket support. Enable IPv6 in this environment. On WSL, remove ipv6.disable=1 from .wslconfig and run wsl --shutdown." + : "The downloaded Antigravity runtime could not start in this environment."; + return installationError("verify", detail, cause); + }), ); const install = Effect.fn("AntigravityInstallation.install")( diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index be709add4f60..ade033551f65 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -15,7 +15,6 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; @@ -179,23 +178,6 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( }); describe("Antigravity model catalog", () => { - it("keeps the captured personal catalog's IDs, labels, order, and selected default", () => { - const models = buildAntigravityModelsFromSession(sessionSetupResult); - expect(models.map((model) => [model.slug, model.name])).toEqual( - modelOptions.map((option) => [option.value, option.name]), - ); - expect(models.filter((model) => model.isDefault).map((model) => model.slug)).toEqual([ - "gemini-3.7-flash-high", - ]); - expect( - models - .filter((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL)) - .map((model) => model.slug), - ).toEqual(["gemini-3.7-flash-high"]); - expect(models.every((model) => model.capabilities?.optionDescriptors?.length === 0)).toBe(true); - expect(models.every((model) => !model.isCustom)).toBe(true); - }); - it("uses legacy session models only when model config is absent", () => { const fromLegacy = buildAntigravityModelsFromSession({ models: sessionSetupResult.models, @@ -257,45 +239,6 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { ), ); - it.effect("ignores account updates while disabled", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness({ enabled: false }); - yield* harness.provider.onSessionStarted(started); - yield* harness.provider.onAvailableCommands(commands); - yield* harness.provider.onSignedOut; - const snapshot = yield* harness.provider.snapshot.refresh; - expect(snapshot).toMatchObject({ - enabled: false, - installed: false, - status: "disabled", - auth: { status: "unknown" }, - models: [], - slashCommands: [], - workspaceSnapshots: [], - }); - expect(yield* Ref.get(harness.probeCalls)).toBe(0); - }), - ), - ); - - it.effect("publishes the configured sign-in method before any account is checked", () => - Effect.scoped( - Effect.gen(function* () { - const provider = yield* makeAntigravityProvider(decodeSettings({ enabled: true }), { - stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), - probe: Effect.succeed(initializeResult), - supportsTextGeneration: Effect.succeed(true), - auth: { type: "gemini-api-key", label: "Gemini API key" }, - }); - expect((yield* provider.snapshot.getSnapshot).auth).toEqual({ - status: "unknown", - type: "gemini-api-key", - }); - }), - ), - ); - it.effect("treats initialize as installation proof, not account or model discovery", () => Effect.scoped( Effect.gen(function* () { @@ -351,21 +294,6 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { ), ); - it.effect("does not retain a disposable sign-in workspace", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - yield* harness.provider.onSessionStarted(started); - yield* harness.provider.onAvailableCommands(commands); - const snapshot = yield* harness.provider.snapshot.getSnapshot; - expect(snapshot.models).toHaveLength(11); - expect(snapshot.slashCommands).toEqual(expectedCommands); - expect(snapshot.workspaceSnapshots).toEqual([]); - }), - ), - ); - it.effect("clears all account metadata on sign-out and authentication failure", () => Effect.scoped( Effect.gen(function* () { @@ -444,30 +372,6 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { ), ); - it.effect("accepts a known version from a later installation probe", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - yield* harness.provider.onSessionStarted({ - ...started, - initializeResult: { - ...initializeResult, - agentInfo: { ...initializeResult.agentInfo, version: "1.3.0" }, - }, - }); - yield* Ref.set( - harness.probe, - Effect.succeed({ - ...initializeResult, - agentInfo: { ...initializeResult.agentInfo, version: "1.4.0" }, - }), - ); - expect((yield* harness.provider.snapshot.refresh).version).toBe("1.4.0"); - }), - ), - ); - it.effect("does not replace a handshake version with an unknown custom-launcher probe", () => Effect.scoped( Effect.gen(function* () { @@ -539,58 +443,6 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { ), ); - it.effect("allows a slow packaged runtime health check to finish", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - const entered = yield* Deferred.make(); - const initialized = yield* Deferred.make(); - yield* Ref.set( - harness.probe, - Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(initialized))), - ); - - const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); - yield* Deferred.await(entered); - yield* TestClock.adjust("47 seconds"); - yield* Deferred.succeed(initialized, initializeResult); - const snapshot = yield* Fiber.join(refresh); - - expect(snapshot).toMatchObject({ - installed: true, - status: "warning", - auth: { status: "unknown" }, - }); - }), - ), - ); - - it.effect("closes a stalled health probe at its deadline", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - const entered = yield* Deferred.make(); - const closed = yield* Deferred.make(); - yield* Ref.set( - harness.probe, - Deferred.succeed(entered, undefined).pipe( - Effect.andThen(Effect.never), - Effect.ensuring(Deferred.succeed(closed, undefined)), - ), - ); - const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); - yield* Deferred.await(entered); - yield* TestClock.adjust("90 seconds"); - const snapshot = yield* Fiber.join(refresh); - yield* Deferred.await(closed); - expect(snapshot.status).toBe("error"); - expect(snapshot.message).toContain("90 seconds"); - }), - ), - ); - it.effect("distinguishes missing executables from a failed installed executable", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 9e1019c141fd..80740b0a21d4 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -1,67 +1,11 @@ // @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. -import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; import { vi } from "vite-plus/test"; -import * as Deferred from "effect/Deferred"; -import * as Fiber from "effect/Fiber"; -import * as TestClock from "effect/testing/TestClock"; -import { - ClaudeSettings, - ProviderDriverKind, - ProviderInstanceId, - type ServerProvider, -} from "@t3tools/contracts"; -import * as NodeFSP from "node:fs/promises"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import * as Result from "effect/Result"; -import { - buildClaudeCapabilitiesProbeQueryOptions, - CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, - probeClaudeCapabilities, - probeClaudeWorkspaceSnapshot, -} from "./ClaudeProvider.ts"; -import { COMPACT_SLASH_COMMAND } from "@t3tools/provider-core/server/snapshotProbe"; +import { buildClaudeCapabilitiesProbeQueryOptions } from "./ClaudeProvider.ts"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); -const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); - -it("isolates Claude capability probes without dropping workspace setting sources", () => { - const abortController = new AbortController(); - const options = buildClaudeCapabilitiesProbeQueryOptions({ - executablePath: "/usr/bin/claude", - abortController, - environment: { - HOME: "/home/user", - ENABLE_CLAUDEAI_MCP_SERVERS: "true", - FORCE_CODE_TERMINAL: "1", - }, - cwd: "/workspace/project", - managedMcpConfig: false, - }); - - assert.deepEqual(options.mcpServers, {}); - assert.equal(options.strictMcpConfig, true); - assert.equal(options.cwd, "/workspace/project"); - assert.deepEqual(options.settingSources, [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES]); - assert.deepEqual(options.settings, { disableAllHooks: true }); - assert.deepEqual(options.allowedTools, []); - assert.equal(options.persistSession, false); - assert.equal(options.pathToClaudeCodeExecutable, "/usr/bin/claude"); - assert.equal(options.abortController, abortController); - assert.equal(options.env?.HOME, "/home/user"); - assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); - assert.equal(options.env?.FORCE_CODE_TERMINAL, undefined); - assert.equal(options.env?.CLAUDE_CODE_AUTO_CONNECT_IDE, "0"); - assert.equal(options.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL, "1"); -}); - it("drops strict MCP mode, and only that, next to an administrator's managed-mcp.json", () => { const options = buildClaudeCapabilitiesProbeQueryOptions({ executablePath: "/usr/bin/claude", @@ -76,347 +20,3 @@ it("drops strict MCP mode, and only that, next to an administrator's managed-mcp assert.deepEqual(options.mcpServers, {}); assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); }); - -it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { - it.effect( - "discovers commands and skills separately for each cwd without replacing machine metadata", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspaces-" }); - const configDir = path.join(tempDir, "claude-home"); - const workspaces = [path.join(tempDir, "one"), path.join(tempDir, "two")]; - for (const cwd of workspaces) { - const skillDir = path.join(cwd, ".claude", "skills", "existing-skill"); - yield* fs.makeDirectory(skillDir, { recursive: true }); - yield* fs.writeFileString( - path.join(skillDir, "SKILL.md"), - "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.", - ); - } - const machineSnapshot = { - instanceId: ProviderInstanceId.make("claude"), - driver: ProviderDriverKind.make("claudeAgent"), - enabled: true, - installed: true, - status: "ready", - auth: { status: "authenticated", email: "machine@example.com" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "2.1.288", - models: [], - slashCommands: [{ name: "server-cwd-only" }], - skills: [], - } satisfies ServerProvider; - let usageCalls = 0; - const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { - assert.equal(options?.env?.CLAUDE_CONFIG_DIR, configDir); - assert.equal(options?.env?.T3_WORKSPACE_PROBE, "owned-instance"); - return { - initializationResult: async () => ({ - account: { email: "workspace@example.com" }, - commands: [ - { - name: options?.cwd === workspaces[0] ? "start-session" : "other-project", - description: "Project command", - argumentHint: "[topic]", - }, - { name: "nested:review", description: "Review changes", argumentHint: "" }, - { name: "NESTED:review", description: "", argumentHint: "[path]" }, - { name: "compact", description: "Provider compact", argumentHint: "" }, - { name: "user-command", description: "Existing user command", argumentHint: "" }, - ], - }), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => { - usageCalls++; - return { rate_limits_available: false, rate_limits: null }; - }, - } as ReturnType; - }); - yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - for (const [index, cwd] of workspaces.entries()) { - const scoped = yield* probeClaudeWorkspaceSnapshot( - decodeClaudeSettings({ homePath: configDir }), - machineSnapshot, - cwd, - { ...process.env, T3_WORKSPACE_PROBE: "owned-instance" }, - ); - assert.deepEqual(scoped, { - ...machineSnapshot, - slashCommandsPending: false, - slashCommands: [ - COMPACT_SLASH_COMMAND, - { - name: index === 0 ? "start-session" : "other-project", - description: "Project command", - input: { hint: "[topic]" }, - }, - { name: "nested:review", description: "Review changes", input: { hint: "[path]" } }, - { name: "user-command", description: "Existing user command" }, - ], - skills: [ - { - name: "existing-skill", - path: path.join(cwd, ".claude", "skills", "existing-skill", "SKILL.md"), - enabled: true, - scope: "project", - description: "Existing project skill", - }, - ], - }); - } - assert.deepEqual( - query.mock.calls.map(([input]) => input.options?.cwd), - workspaces, - ); - assert.equal(usageCalls, 0); - }).pipe(Effect.scoped), - ); - - it.effect("keeps readable skills during failed command discovery and recovers on retry", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspace-retry-" }); - const skillDir = path.join(cwd, ".claude", "skills", "existing-skill"); - yield* fs.makeDirectory(skillDir, { recursive: true }); - yield* fs.writeFileString( - path.join(skillDir, "SKILL.md"), - "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.", - ); - const machineSnapshot = { - instanceId: ProviderInstanceId.make("claude"), - driver: ProviderDriverKind.make("claudeAgent"), - enabled: true, - installed: true, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "2.1.288", - models: [], - slashCommands: [{ name: "server-cwd-only" }], - skills: [], - } satisfies ServerProvider; - const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( - () => - ({ - initializationResult: () => - Promise.reject( - new Error("Initialization failed"), - ), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ - rate_limits_available: false, - rate_limits: null, - }), - }) as ReturnType, - ); - yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - const settings = decodeClaudeSettings({ homePath: cwd }); - const failed = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd); - assert.deepEqual(failed, { - ...machineSnapshot, - slashCommands: [COMPACT_SLASH_COMMAND], - slashCommandsPending: true, - skills: [ - { - name: "existing-skill", - path: path.join(skillDir, "SKILL.md"), - enabled: true, - scope: "project", - description: "Existing project skill", - }, - ], - }); - query.mockImplementation( - () => - ({ - initializationResult: async () => ({ - commands: [{ name: "recovered", description: "", argumentHint: "" }], - }), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ - rate_limits_available: false, - rate_limits: null, - }), - }) as ReturnType, - ); - const recovered = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd); - assert.deepEqual(recovered.slashCommands, [COMPACT_SLASH_COMMAND, { name: "recovered" }]); - assert.equal(recovered.status, "ready"); - assert.equal(recovered.slashCommandsPending, false); - assert.deepEqual(recovered.skills, failed.skills); - const disabled = yield* probeClaudeWorkspaceSnapshot( - { ...settings, enabled: false }, - machineSnapshot, - cwd, - ); - assert.equal(disabled, machineSnapshot); - assert.equal(query.mock.calls.length, 2); - }).pipe(Effect.scoped), - ); - - it.effect("serializes strict no-MCP options and still resolves account capabilities", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" }); - const executablePath = yield* path.fromFileUrl( - new URL("./testing/ClaudeCapabilitiesProbe.fixture.mjs", import.meta.url), - ); - const invocationPath = path.join(tempDir, "invocation.json"); - // The probe aborts the SDK without awaiting the child's exit, and on - // Windows a directory that is still some process's cwd cannot be - // removed. Keep the workspace outside the scoped directory and let it - // go with a retrying removal once the child has gone. - const workspaceCwd = yield* fs.makeTempDirectory({ prefix: "t3-claude-probe-cwd-" }); - // Node's own retry rather than an Effect schedule: it.effect runs on a - // TestClock, so a scheduled retry would wait for time nobody advances. - yield* Effect.addFinalizer(() => - Effect.promise(() => - NodeFSP.rm(workspaceCwd, { - recursive: true, - force: true, - maxRetries: 20, - retryDelay: 250, - }).catch(() => undefined), - ), - ); - - const capabilities = yield* probeClaudeCapabilities( - decodeClaudeSettings({ binaryPath: executablePath }), - { - ...process.env, - T3_PROBE_INVOCATION_PATH: invocationPath, - ENABLE_CLAUDEAI_MCP_SERVERS: "true", - }, - workspaceCwd, - ); - - assert.deepEqual(capabilities, { - email: "dev@example.com", - subscriptionType: "pro", - tokenSource: "oauth", - apiKeySource: undefined, - apiProvider: undefined, - slashCommands: [ - { - name: "review", - description: "Review changes", - input: { hint: "[path]" }, - }, - ], - usage: { - rate_limits_available: true, - rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, - }, - }); - - const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as { - readonly args: ReadonlyArray; - readonly cwd: string; - readonly connectorEnv: string; - readonly mcpConfig: unknown; - }; - assert.equal(invocation.cwd, yield* fs.realPath(workspaceCwd)); - assert.equal(invocation.connectorEnv, "false"); - assert.equal(invocation.args.includes("--strict-mcp-config"), true); - assert.equal(invocation.args.includes("--mcp-config"), false); - assert.equal(invocation.mcpConfig, undefined); - - assert.equal(invocation.args.includes("--setting-sources=user,project,local"), true); - - const settingsFlagIndex = invocation.args.indexOf("--settings"); - assert.notEqual(settingsFlagIndex, -1); - const flagSettings = JSON.parse(invocation.args[settingsFlagIndex + 1] ?? "{}") as { - readonly disableAllHooks?: boolean; - }; - assert.equal(flagSettings.disableAllHooks, true); - }).pipe(Effect.scoped), - ); -}); - -it.effect("preserves initialized capabilities when optional usage times out", () => - Effect.gen(function* () { - const usageStarted = yield* Deferred.make(); - let abortSignal: AbortSignal | undefined; - const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { - abortSignal = options?.abortController?.signal; - return { - initializationResult: async () => ({ - account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, - commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], - }), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => { - Deferred.doneUnsafe(usageStarted, Effect.void); - return new Promise(() => {}); - }, - } as ReturnType; - }); - yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - const probe = yield* probeClaudeCapabilities( - decodeClaudeSettings({ binaryPath: "claude" }), - ).pipe(Effect.forkChild); - yield* Deferred.await(usageStarted); - yield* TestClock.adjust("4 seconds"); - const capabilities = yield* Fiber.join(probe); - assert.equal(capabilities?.email, "dev@example.com"); - assert.equal(capabilities?.subscriptionType, "pro"); - assert.equal(capabilities?.tokenSource, "oauth"); - assert.deepEqual(capabilities?.slashCommands, [ - { name: "review", description: "Review changes", input: { hint: "[path]" } }, - ]); - assert.equal(capabilities?.usage, undefined); - assert.equal(abortSignal?.aborted, true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect("fails a hung initialization probe with a timeout", () => - Effect.gen(function* () { - const initializationStarted = yield* Deferred.make(); - const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( - () => - ({ - initializationResult: () => { - Deferred.doneUnsafe(initializationStarted, Effect.void); - return new Promise(() => {}); - }, - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ - rate_limits_available: true, - rate_limits: {}, - }), - }) as ReturnType, - ); - yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - const probe = yield* probeClaudeCapabilities( - decodeClaudeSettings({ binaryPath: "claude" }), - ).pipe(Effect.result, Effect.forkChild); - - yield* Deferred.await(initializationStarted); - yield* TestClock.adjust("25 seconds"); - const result = yield* Fiber.join(probe); - - assert.isTrue(Result.isFailure(result)); - if (Result.isFailure(result)) assert.isTrue(Cause.isTimeoutError(result.failure)); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect("asks for usage without the local transcript scan", () => - Effect.gen(function* () { - let usageOptions: unknown; - const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( - () => - ({ - initializationResult: async () => ({ - account: { email: "dev@example.com", subscriptionType: "max", tokenSource: "oauth" }, - commands: [], - }), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async (options?: unknown) => { - usageOptions = options; - return { rate_limits_available: true, rate_limits: null }; - }, - }) as unknown as ReturnType, - ); - yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - yield* probeClaudeCapabilities(decodeClaudeSettings({ binaryPath: "claude" })); - assert.deepEqual(usageOptions, { skipBehaviors: true }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index da61038215d8..2092429f6d55 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -1,7 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; -import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; import { resolveProviderCatalog, type ModelManifestData } from "./ModelManifest.ts"; import { formatClaudeVersionUpgradeMessage, @@ -171,37 +170,6 @@ describe("Claude model catalog", () => { ); }); - it("rejects malformed adapter mappings", () => { - const base = manifest(); - const malformed: ModelManifestData = { - ...base, - providers: { - ...base.providers, - claudeAgent: { - ...base.providers!.claudeAgent!, - profiles: { - ...base.providers!.claudeAgent!.profiles, - synthetic: { - ...base.providers!.claudeAgent!.profiles.synthetic!, - adapter: { claudeCode: { effortMap: { extreme: 123 } } }, - }, - }, - }, - }, - }; - assert.isFalse(hasValidClaudeManifestAdapters(malformed)); - }); - - it("preserves only SDK effort levels for uncatalogued models", () => { - const catalog = catalogFromManifest(manifest()); - for (const effort of ["low", "medium", "high", "xhigh", "max"]) { - assert.strictEqual(resolveClaudeCatalogEffort(catalog, "custom", ` ${effort} `), effort); - } - for (const effort of [undefined, null, "", " ", "brutal", "extreme", "HIGH", "ultrathink"]) { - assert.isUndefined(resolveClaudeCatalogEffort(catalog, "custom", effort)); - } - }); - it("appends custom models with their own descriptors and keeps bare slugs opaque", () => { const catalog = scopeClaudeModelCatalog(catalogFromManifest(manifest()), [ "synthetic", diff --git a/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts b/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts index 1a686b62c38c..762f919dc39c 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts @@ -3,7 +3,7 @@ import type { ClaudeModelCatalog } from "./ClaudeModelCatalog.ts"; export const SYNTHETIC_CLAUDE_CAPABLE_MODEL = "claude-synthetic-capable"; export const SYNTHETIC_CLAUDE_COLLIDING_ALIAS = "synthetic-collision"; export const SYNTHETIC_CLAUDE_STANDARD_MODEL = "claude-synthetic-standard"; -export const SYNTHETIC_CLAUDE_THINKING_MODEL = "claude-synthetic-thinking"; +const SYNTHETIC_CLAUDE_THINKING_MODEL = "claude-synthetic-thinking"; const effort = { id: "effort", @@ -71,7 +71,9 @@ export const SYNTHETIC_CLAUDE_MODEL_CATALOG: ClaudeModelCatalog = { name: "Claude Synthetic Thinking", isCustom: false, capabilities: { - optionDescriptors: [{ id: "thinking", label: "Thinking", type: "boolean" }], + optionDescriptors: [ + { id: "thinking", label: "Thinking", type: "boolean", currentValue: true }, + ], }, }, runtime: {}, diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index a30e58f177c3..18f3b8508a04 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -194,8 +194,10 @@ function claudeAuthStatus( // Amazon Bedrock initializes far slower than first-party auth: the SDK boots the Bedrock // backend and runs the `awsAuthRefresh` credential hook before returning account info. const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; +// Native CLI startup on Windows can exceed the shared probe deadline. +const CLAUDE_VERSION_CHECK_TIMEOUT_MS = 20_000; -export const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [ +const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [ "user", "project", "local", @@ -263,6 +265,7 @@ export type ClaudeCapabilitiesProbe = { * otherwise successful response mean the account has none (API key). */ readonly usage?: Pick; + readonly usageCheckedAt?: string; }; function parseClaudeInitializationCommands( @@ -383,6 +386,7 @@ const probeClaudeCapabilities = ( // Usage has its own deadline so a slow optional request cannot discard initialization. // Only the rate limits are read, so skip the local transcript scan that fills // `behaviors`: with a few GB of transcripts it outlasts the deadline. + const usageCheckedAt = DateTime.formatIso(yield* DateTime.now); const usageResult = includeUsage ? yield* Effect.tryPromise(() => q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true }), @@ -411,7 +415,7 @@ const probeClaudeCapabilities = ( apiKeySource: account?.apiKeySource, apiProvider: account?.apiProvider, slashCommands: parseClaudeInitializationCommands(init.commands), - ...(usage ? { usage } : {}), + ...(usage ? { usage, usageCheckedAt } : {}), } satisfies ClaudeCapabilitiesProbe; }), ), @@ -514,7 +518,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ["--version"], resolvedEnvironment, cwd, - ).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); + ).pipe(Effect.timeoutOption(CLAUDE_VERSION_CHECK_TIMEOUT_MS), Effect.result); if (Result.isFailure(versionProbe)) { const error = versionProbe.failure; @@ -620,7 +624,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - if (claudeAuthStatus(capabilities) === "unauthenticated") { + // First-party account fields cannot verify custom endpoint credentials. + const endpoint = URL.parse(resolvedEnvironment.ANTHROPIC_BASE_URL?.trim() ?? ""); + const customApiEndpoint = + endpoint !== null && + (endpoint.protocol === "http:" || endpoint.protocol === "https:") && + endpoint.hostname !== "" && + endpoint.hostname !== "api.anthropic.com"; + if (!customApiEndpoint && claudeAuthStatus(capabilities) === "unauthenticated") { return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -642,15 +653,21 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, authMethod: capabilities.tokenSource, - }) ?? apiProviderAuthMetadata(capabilities.apiProvider); + }) ?? + apiProviderAuthMetadata(capabilities.apiProvider) ?? + (customApiEndpoint ? { type: "customEndpoint", label: "Custom API endpoint" } : undefined); + const usageCheckedAt = capabilities.usageCheckedAt ?? checkedAt; const usageLimits = !capabilities.usage ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" }) : scopedLimitNames ? yield* recordClaudeUsageResponse(scopedLimitNames, { response: capabilities.usage, - checkedAt, + checkedAt: usageCheckedAt, }) - : claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits; + : claudeUsageResponseToLimits({ + response: capabilities.usage, + checkedAt: usageCheckedAt, + }).limits; const resetCredits = resolveResetCredits && capabilities.subscriptionType && @@ -684,11 +701,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const nowIso = Effect.map(DateTime.now, DateTime.formatIso); -export const makePendingClaudeProvider = ( - claudeSettings: ClaudeSettings, - modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, -): Effect.Effect => - Effect.gen(function* () { +export const makePendingClaudeProvider = Effect.fn("ClaudeProvider.makePendingClaudeProvider")( + function* ( + claudeSettings: ClaudeSettings, + modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, + ): Effect.fn.Return { const checkedAt = yield* nowIso; const models = providerModelsFromSettings( modelCatalog.models.map((entry) => entry.model), @@ -725,6 +742,7 @@ export const makePendingClaudeProvider = ( message: "Claude provider status has not been checked in this session yet.", }, }); - }); + }, +); export { probeClaudeCapabilities }; diff --git a/apps/server/src/provider/CodexChatGptAuth.test.ts b/apps/server/src/provider/CodexChatGptAuth.test.ts index 859537b25cb6..7bcb7413a157 100644 --- a/apps/server/src/provider/CodexChatGptAuth.test.ts +++ b/apps/server/src/provider/CodexChatGptAuth.test.ts @@ -10,14 +10,12 @@ import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as Fiber from "effect/Fiber"; import * as Deferred from "effect/Deferred"; -import * as TestClock from "effect/testing/TestClock"; import { subscribeChatGptHandoff } from "./CodexChatGptHandoff.ts"; import { FetchHttpClient } from "effect/http"; import { exportJWK, generateKeyPair, SignJWT } from "jose"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; -import * as ServerSettings from "../serverSettings.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; @@ -220,6 +218,7 @@ const makeHarnessFor = Effect.fnUntraced(function* ( analyticsEvents.push({ event, properties }); }), flush: Effect.void, + recordOwners: () => Effect.void, }); const auth = yield* makeCodexChatGptAuth({ instanceId, @@ -454,33 +453,6 @@ it.effect.each(["identity", "sharing"] as const)( }), ), ); -it.effect("retains the callback host and path after controller recreation and token removal", () => - provision( - Effect.gen(function* () { - const bytes = new Map(); - const first = yield* makeHarnessFor(instanceId, bytes); - yield* first.signIn; - yield* first.phase("succeeded"); - const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri"); - yield* first.auth.revoke; - const restarted = yield* makeHarnessFor(instanceId, bytes); - yield* restarted.signIn; - yield* restarted.phase("succeeded"); - assert.strictEqual( - restarted.authorizationRequests[0]!.searchParams.get("client_id"), - "oaiapp_test", - ); - assertSameCallback( - restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), - redirectUri, - ); - assert.strictEqual( - restarted.exchanges[0]!.get("redirect_uri"), - restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), - ); - }), - ), -); it.effect("reauthorizes on an available port when the original callback port is occupied", () => provision( Effect.gen(function* () { @@ -513,30 +485,6 @@ it.effect("reauthorizes on an available port when the original callback port is }), ), ); - -it.effect("registers a fresh client when the original registration is no longer stored", () => - provision( - Effect.gen(function* () { - const bytes = new Map(); - const h = yield* makeHarnessFor(instanceId, bytes); - yield* h.signIn; - yield* h.phase("succeeded"); - for (const [key, value] of bytes) { - const record = JSON.parse(new TextDecoder().decode(value)); - if (record.profiles) bytes.delete(key); - } - h.setCallbackClientId("oaiapp_replacement"); - yield* h.signIn; - yield* h.phase("succeeded"); - assert.strictEqual( - h.authorizationRequests[1]!.searchParams.get("client_id"), - "dynamic_agent_client", - ); - assert.strictEqual(h.exchanges[1]!.get("client_id"), "oaiapp_replacement"); - assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_replacement"); - }), - ), -); it.effect("reauthorizes a registration without persisting its original port", () => provision( Effect.gen(function* () { @@ -715,20 +663,6 @@ it.effect( }), ), ); -it.effect("preserves the Welcome agents step and strips unrelated callback return parameters", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - h.setReturnUrl("http://localhost:7001/welcome?code=never-forward#agents:test-environment"); - yield* h.signIn; - assert.include( - h.callbackResponses[0]!.body, - 'content="1;url=http://localhost:7001/welcome#agents:test-environment"', - ); - assert.notInclude(h.callbackResponses[0]!.body, "never-forward"); - }), - ), -); it.effect( "does not redirect to an arbitrary return URL or claim success after verification fails", () => @@ -891,60 +825,6 @@ it.effect( }), ), ); -it.effect("revoked connection clears tokens but preserves registration for reconnect", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - yield* h.seedExpired; - h.setRevoked(); - yield* h.auth.access.pipe(Effect.result); - assert.isTrue(Option.isNone(yield* h.auth.read)); - assert.deepEqual(h.storedRecords(), [ - { - profiles: [ - { - clientId: "oaiapp_test", - connectionLabel: "Connection 1", - sharingEnabled: true, - redirectUri: h.authorizationRequests[0]!.searchParams.get("redirect_uri"), - subject: "user-test", - email: "hidden@example.test", - }, - ], - lastClientId: "oaiapp_test", - }, - ]); - yield* h.signIn; - yield* h.phase("succeeded"); - assert.strictEqual(h.authorizationRequests[1]?.searchParams.get("client_id"), "oaiapp_test"); - }), - ), -); - -it.effect("fresh sign-in ignores a registration left by the old disconnect behavior", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - yield* h.auth.revoke; - assert.isTrue(Option.isNone(yield* h.auth.read)); - assert.strictEqual(h.storedRecords().length, 1); - h.setCallbackClientId("oaiapp_new_workspace"); - h.setIdentity("new-workspace-user", "new@example.test"); - yield* h.changeAccount; - yield* h.phase("succeeded"); - assert.strictEqual( - h.authorizationRequests[1]!.searchParams.get("client_id"), - "dynamic_agent_client", - ); - assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_new_workspace"); - assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "new-workspace-user"); - }), - ), -); it.effect("rejects mismatched callback state before any token exchange or credential write", () => provision( @@ -996,25 +876,6 @@ it.effect.each([ ), ); -it.effect("returns successful desktop sign-in to the original Welcome step", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - h.setReturnUrl("t3code-dev://app/welcome#agents:test-environment"); - yield* h.signIn; - yield* h.phase("succeeded"); - assert.include( - h.callbackResponses[0]!.body, - 'content="1;url=t3code-dev://app/welcome#agents:test-environment"', - ); - assert.include( - h.callbackResponses[0]!.body, - 'href="t3code-dev://app/welcome#agents:test-environment"', - ); - }), - ), -); - it.effect( "completes remote sign-in on the owning environment and reuses its exact callback for reauthorization", () => @@ -1206,44 +1067,6 @@ it.effect( ), ); -it.effect("fresh sign-in to the same identity preserves separate registration profiles", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - h.setIdentity("other-user", "other@example.test"); - h.setCallbackClientId("oaiapp_other_account"); - yield* h.changeAccount; - yield* h.phase("succeeded"); - h.setIdentity("user-test", "hidden@example.test"); - h.setCallbackClientId("oaiapp_replacement"); - yield* h.changeAccount; - const changed = yield* h.phase("succeeded"); - assert.deepEqual( - changed - .methods!.filter((method) => method.id.startsWith("chatgpt-profile:")) - .map((method) => method.id), - [ - "chatgpt-profile:oaiapp_replacement", - "chatgpt-profile:oaiapp_other_account", - "chatgpt-profile:oaiapp_test", - ], - ); - assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_replacement"); - const redirectUri = h.authorizationRequests[2]!.searchParams.get("redirect_uri"); - yield* h.auth.controller.logout(Effect.void); - yield* h.reconnectProfile("oaiapp_replacement"); - yield* h.phase("succeeded"); - assert.strictEqual( - h.authorizationRequests[3]!.searchParams.get("client_id"), - "oaiapp_replacement", - ); - assertSameCallback(h.authorizationRequests[3]!.searchParams.get("redirect_uri"), redirectUri); - }), - ), -); - it.effect("preserves legacy profiles with the same email and subject", () => provision( Effect.gen(function* () { @@ -1386,38 +1209,6 @@ it.effect("declined sharing on an older profile does not replace the active acco ), ); -it.effect( - "Disconnect preserves a remote profile's callback URI for client-delivered reconnect", - () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - const first = yield* h.startRemote(); - yield* h.auth.controller.complete("owner", { - flowId: first.waiting.flowId!, - callbackUrl: first.callbackUrl, - }); - yield* h.phase("succeeded"); - yield* h.auth.controller.logout(Effect.void); - const second = yield* h.startRemote(); - assert.strictEqual( - new URL(second.waiting.authorizationUrl!).searchParams.get("client_id"), - "oaiapp_test", - ); - assertSameCallback( - new URL(second.waiting.authorizationUrl!).searchParams.get("redirect_uri"), - new URL(first.waiting.authorizationUrl!).searchParams.get("redirect_uri"), - ); - yield* h.auth.controller.complete("owner", { - flowId: second.waiting.flowId!, - callbackUrl: second.callbackUrl, - }); - yield* h.phase("succeeded"); - assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "user-test"); - }), - ), -); - it.effect.each([ "invalid_grant", "invalid_refresh_token", @@ -1481,23 +1272,6 @@ it.effect( ), ); -it.live("logout retries temporary revocation failures and reports local-only sign-out", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - h.setRevocationStatus(503); - const state = yield* h.auth.controller.logout(Effect.void); - assert.lengthOf(h.revocations, 3); - assert.strictEqual(state.phase, "idle"); - assert.include(state.message!, "Remote revocation could not be confirmed"); - assert.isTrue(Option.isNone(yield* h.auth.read)); - assert.isFalse(h.storedRecords().some((record) => record.idToken)); - }), - ), -); - it.effect( "reauth hints come from the selected profile, including after another profile logs out", () => @@ -1647,26 +1421,6 @@ it.effect( ), ); -it.effect("keeps the active connection first when another profile declines sharing", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - h.setCallbackClientId("oaiapp_identity_only"); - h.setIdentity("other-user", "other@example.test"); - h.declineSharing(); - yield* h.changeAccount; - const state = yield* h.phase("failed"); - assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_test"); - const profiles = state.methods!.filter((method) => method.id.startsWith("chatgpt-profile:")); - assert.strictEqual(profiles[0]!.id, "chatgpt-profile:oaiapp_test"); - assert.include(profiles[0]!.name, "Connection 1"); - assert.include(profiles[1]!.name, "Connection 2"); - }), - ), -); - it.effect("primary completes OAuth and destination imports and owns the refresh session", () => provision( Effect.gen(function* () { @@ -1839,66 +1593,6 @@ it.effect( ), ); -it.effect("records one anonymous auth outcome per success, failure, or cancellation", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - h.setInvalidNonce(); - yield* h.signIn; - yield* h.phase("failed"); - const { waiting } = yield* h.startRemote(); - yield* h.auth.controller.cancel("owner", waiting.flowId!); - yield* h.phase("cancelled"); - const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed"); - assert.deepStrictEqual( - completed.map(({ properties }) => properties?.outcome), - ["succeeded", "failed", "cancelled"], - ); - assert.strictEqual( - h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.started").length, - 3, - ); - assert.strictEqual(completed[1]?.properties?.failureStage, "verify"); - for (const { properties } of completed) { - assert.isNumber(properties?.durationMs); - assert.strictEqual(properties?.flow, "direct"); - assert.deepStrictEqual( - Object.keys(properties!).sort(), - [ - ...(properties?.outcome === "succeeded" - ? [ - "accountCountScope", - "connectedAccountCount", - "connectedConnectionCount", - "savedConnectionCount", - "unidentifiedConnectedConnectionCount", - ] - : []), - "callbackMode", - "durationMs", - ...(properties?.failureStage ? ["failureStage"] : []), - "flow", - "intent", - "outcome", - ].sort(), - ); - } - const serialized = JSON.stringify(h.analyticsEvents); - for (const secret of [ - "hidden@example.test", - "oaiapp_test", - "user-test", - "access_token", - "refresh_token", - ]) { - assert.notInclude(serialized, secret); - } - }), - ), -); - it.effect("telemetry failures do not fail a verified ChatGPT sign-in", () => provision( Effect.gen(function* () { @@ -1910,142 +1604,3 @@ it.effect("telemetry failures do not fail a verified ChatGPT sign-in", () => }), ), ); - -it.effect("counts accounts separately from saved connections across additions and reconnects", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - yield* h.signIn; - yield* h.phase("succeeded"); - h.setCallbackClientId("oaiapp_same_account"); - h.setIdentity("another-client-subject", "HIDDEN@example.test"); - yield* h.changeAccount; - yield* h.phase("succeeded"); - h.setCallbackClientId("oaiapp_other_account"); - h.setIdentity("other-user", "other@example.test"); - yield* h.changeAccount; - yield* h.phase("succeeded"); - yield* h.auth.controller.logout(Effect.void); - h.setCallbackClientId("oaiapp_test"); - h.setIdentity("user-test", "hidden@example.test"); - yield* h.reconnectProfile("oaiapp_test"); - yield* h.phase("succeeded"); - const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed"); - assert.deepStrictEqual( - completed.map(({ properties }) => [ - properties?.connectedAccountCount, - properties?.connectedConnectionCount, - properties?.savedConnectionCount, - ]), - [ - [1, 1, 1], - [1, 1, 1], - [1, 2, 2], - [2, 3, 3], - [1, 2, 3], - ], - ); - assert.isTrue( - completed.every(({ properties }) => properties?.unidentifiedConnectedConnectionCount === 0), - ); - assert.notInclude(JSON.stringify(h.analyticsEvents), "example.test"); - }), - ), -); - -it.effect("includes accounts from other Codex instances in the environment", () => - provision( - Effect.gen(function* () { - const bytes = new Map(); - const personal = yield* makeHarnessFor(instanceId, bytes); - const work = yield* makeHarnessFor(ProviderInstanceId.make("managed-work"), bytes); - yield* personal.signIn; - yield* personal.phase("succeeded"); - work.setCallbackClientId("oaiapp_work"); - work.setIdentity("work-user", "work@example.test"); - yield* work.signIn; - yield* work.phase("succeeded"); - const completed = work.analyticsEvents.find( - ({ event }) => event === "chatgpt.auth.completed", - ); - assert.strictEqual(completed?.properties?.accountCountScope, "environment"); - assert.strictEqual(completed?.properties?.connectedAccountCount, 2); - assert.strictEqual(completed?.properties?.connectedConnectionCount, 2); - assert.strictEqual(completed?.properties?.savedConnectionCount, 2); - }).pipe( - Effect.provide( - ServerSettings.layerTest({ - providerInstances: { - [instanceId]: { driver: "codex", enabled: true }, - [ProviderInstanceId.make("managed-work")]: { driver: "codex", enabled: true }, - }, - }), - ), - ), - ), -); - -it.effect("an unreadable unrelated profile omits counts without failing sign-in", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - const unrelated = yield* ProviderCredentialStore.make("codex-chatgpt", "codex").pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, h.secrets), - ); - h.bytes.set(unrelated.binding.key, new TextEncoder().encode("invalid")); - yield* h.signIn; - yield* h.phase("succeeded"); - const completed = h.analyticsEvents.find(({ event }) => event === "chatgpt.auth.completed"); - assert.strictEqual(completed?.properties?.outcome, "succeeded"); - assert.notProperty(completed?.properties ?? {}, "connectedAccountCount"); - }).pipe(Effect.provide(ServerSettings.layerTest())), - ), -); - -it.effect("reports connections without an email separately from identifiable accounts", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId).pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, h.secrets), - ); - yield* store.set( - new TextEncoder().encode( - JSON.stringify({ - activeClientId: "oaiapp_test", - sessions: [{ ...Option.getOrThrow(yield* h.auth.read), email: null }], - }), - ), - ); - h.setCallbackClientId("oaiapp_other_account"); - h.setIdentity("other-user", "other@example.test"); - yield* h.changeAccount; - yield* h.phase("succeeded"); - const completed = h.analyticsEvents.findLast( - ({ event }) => event === "chatgpt.auth.completed", - ); - assert.strictEqual(completed?.properties?.connectedAccountCount, 1); - assert.strictEqual(completed?.properties?.connectedConnectionCount, 2); - assert.strictEqual(completed?.properties?.unidentifiedConnectedConnectionCount, 1); - }), - ), -); - -it.effect("records an expired auth outcome when sign-in reaches its deadline", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.startRemote(); - yield* TestClock.adjust(300_001); - assert.include((yield* h.phase("failed")).message ?? "", "expired"); - const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed"); - assert.strictEqual(completed.length, 1); - assert.strictEqual(completed[0]?.properties?.outcome, "expired"); - assert.isAtLeast(completed[0]?.properties?.durationMs as number, 300_000); - }), - ), -); diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts index 6a393ace4c60..8b9190c40d18 100644 --- a/apps/server/src/provider/CodexChatGptAuth.ts +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -9,9 +9,11 @@ import { type ProviderInstanceId, } from "@t3tools/contracts"; import { codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff"; +import { hashTelemetryId } from "@t3tools/shared/telemetryId"; import * as Clock from "effect/Clock"; import * as Cause from "effect/Cause"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; +import { openAiUserId } from "../telemetry/Identify.ts"; import * as Crypto from "effect/Crypto"; import * as Exit from "effect/Exit"; import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts"; @@ -109,49 +111,48 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* }) { const analytics = yield* Effect.serviceOption(AnalyticsService.AnalyticsService); const settings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService); - const track = ( + const track = Effect.fn("CodexChatGptAuth.track")(function* ( event: "auth" | "transfer", properties: Readonly>, task: Effect.Effect, expiresAt?: number, - ) => - Effect.gen(function* () { - if (Option.isNone(analytics)) return yield* task; - const record = (name: string, properties: Readonly>) => - analytics.value.record(name, properties).pipe(Effect.ignoreCause); - const startedAt = yield* Clock.currentTimeMillis; - yield* record(`chatgpt.${event}.started`, properties); - return yield* task.pipe( - Effect.onExit((result) => - Effect.gen(function* () { - const endedAt = yield* Clock.currentTimeMillis; - const error = Exit.isFailure(result) - ? Cause.findErrorOption(result.cause) - : Option.none(); - const counts = - Exit.isSuccess(result) && - (event === "transfer" || options.telemetryFlow !== "primary_handoff") - ? yield* accountCounts.pipe(Effect.catchCause(() => Effect.succeed({}))) - : {}; - yield* record(`chatgpt.${event}.completed`, { - ...counts, - ...properties, - outcome: Exit.isSuccess(result) - ? "succeeded" - : Cause.hasInterruptsOnly(result.cause) - ? expiresAt !== undefined && endedAt >= expiresAt - ? "expired" - : "cancelled" - : "failed", - durationMs: Math.max(0, endedAt - startedAt), - ...(Option.isSome(error) && isSetupError(error.value) - ? { failureStage: error.value.operation } - : {}), - }); - }), - ), - ); - }); + ) { + if (Option.isNone(analytics)) return yield* task; + const record = (name: string, properties: Readonly>) => + analytics.value.record(name, properties).pipe(Effect.ignoreCause); + const startedAt = yield* Clock.currentTimeMillis; + yield* record(`chatgpt.${event}.started`, properties); + return yield* task.pipe( + Effect.onExit((result) => + Effect.gen(function* () { + const endedAt = yield* Clock.currentTimeMillis; + const error = Exit.isFailure(result) + ? Cause.findErrorOption(result.cause) + : Option.none(); + const counts = + Exit.isSuccess(result) && + (event === "transfer" || options.telemetryFlow !== "primary_handoff") + ? yield* accountCounts.pipe(Effect.catchCause(() => Effect.succeed({}))) + : {}; + yield* record(`chatgpt.${event}.completed`, { + ...counts, + ...properties, + outcome: Exit.isSuccess(result) + ? "succeeded" + : Cause.hasInterruptsOnly(result.cause) + ? expiresAt !== undefined && endedAt >= expiresAt + ? "expired" + : "cancelled" + : "failed", + durationMs: Math.max(0, endedAt - startedAt), + ...(Option.isSome(error) && isSetupError(error.value) + ? { failureStage: error.value.operation } + : {}), + }); + }), + ), + ); + }); const http = yield* HttpClient.HttpClient; const store = yield* ProviderCredentialStore.make("codex-chatgpt", options.instanceId); const registrationStore = yield* ProviderCredentialStore.make( @@ -222,6 +223,18 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* saved.sessions.find((session) => session.clientId === saved.activeClientId), ), ), + // The OpenAI account is the person, so it marks who owns this install. + Effect.tap((credentials) => { + const userId = Option.flatMap(credentials, (saved) => + Option.flatMap(Option.fromUndefinedOr(saved.idToken), openAiUserId), + ); + return Option.isSome(userId) && Option.isSome(analytics) + ? analytics.value.recordOwners( + [{ source: "openai", ownerId: hashTelemetryId(userId.value) }], + "server", + ) + : Effect.void; + }), ); const writeSessions = (saved: typeof Sessions.Type) => encodeSessions(saved).pipe( diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index 0808d89db9d6..14a613967c11 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.test.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -8,7 +8,6 @@ import { describe, it } from "vite-plus/test"; import { buildCodexAdditionalContext, - buildCodexDeveloperInstructions, CODEX_ADDITIONAL_CONTEXT_VALUE_MAX_BYTES, } from "./CodexDeveloperInstructions.ts"; @@ -62,57 +61,6 @@ describe("buildCodexAdditionalContext", () => { }); describe("buildCodexDeveloperInstructions", () => { - it("appends runtime info after the mode instructions", () => { - const instructions = runtimeInstructions({ - model: "gpt-5.3-codex", - reasoningEffort: "high", - }); - - NodeAssert.match( - buildCodexDeveloperInstructions("default"), - /^# Collaboration Mode: Default/, - ); - NodeAssert.match(instructions, /T3 Code/); - NodeAssert.match(instructions, /Codex harness/); - NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); - }); - - it("describes Markdown media support in the runtime context in both modes", () => { - { - const instructions = runtimeInstructions({ - model: "gpt-5.3-codex", - reasoningEffort: "high", - }); - NodeAssert.match( - instructions, - /.*embed images and videos.*Markdown.*<\/runtime_info>/, - ); - } - }); - - it("includes runtime info alongside plan mode instructions", () => { - const instructions = runtimeInstructions({ - model: "gpt-5.3-codex", - reasoningEffort: "medium", - }); - - NodeAssert.match(buildCodexDeveloperInstructions("plan"), /^# Plan Mode/); - NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); - }); - - it("varies with the model and effort of each turn", () => { - const first = runtimeInstructions({ - model: "gpt-5.3-codex", - reasoningEffort: "medium", - }); - const second = runtimeInstructions({ - model: "gpt-5.4", - reasoningEffort: "high", - }); - - NodeAssert.notEqual(first, second); - }); - it("flattens multiline metadata into single-line runtime info", () => { const instructions = runtimeInstructions({ model: "gpt\n5.3\ncodex", @@ -124,43 +72,6 @@ describe("buildCodexDeveloperInstructions", () => { }); }); -describe("T3 browser developer instructions", () => { - const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; - - it("prefers the product-native preview tools in both collaboration modes", () => { - { - const instructions = toolInstructions(runtime, true); - NodeAssert.match(instructions, /t3-code/); - NodeAssert.match(instructions, /preview_status/); - NodeAssert.match(instructions, /preview_open/); - NodeAssert.match(instructions, /Do not switch to global browser skills/); - } - }); - - it("omits the browser block entirely when the preview tools are not attached", () => { - for (const mode of ["default", "plan"] as const) { - const instructions = toolInstructions(runtime, false); - NodeAssert.doesNotMatch(instructions, /preview_status/); - NodeAssert.doesNotMatch(instructions, /preview_open/); - NodeAssert.doesNotMatch(instructions, /T3 Code collaborative browser/); - NodeAssert.doesNotMatch(instructions, /Do not switch to global browser skills/); - NodeAssert.match(buildCodexDeveloperInstructions(mode), //); - NodeAssert.match(buildCodexDeveloperInstructions(mode), /<\/collaboration_mode>/); - } - }); - - it("tracks the turn's MCP configuration rather than defaulting to on", () => { - NodeAssert.match(toolInstructions(runtime, true), /preview_open/); - NodeAssert.doesNotMatch(toolInstructions(runtime, false), /preview_open/); - }); -}); - function runtimeInstructions(runtime: Parameters[0]) { return buildCodexAdditionalContext(runtime).t3_code_runtime!.value; } -function toolInstructions( - runtime: Parameters[0], - available: boolean, -) { - return buildCodexAdditionalContext(runtime, available).t3_code_tools?.value ?? ""; -} diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 7db4563cb445..5c9f51e55230 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -190,7 +190,7 @@ In Default mode, strongly prefer making reasonable assumptions and executing the export interface CodexRuntimeInfo { readonly model: string; readonly modelName?: string | undefined; - readonly reasoningEffort: string; + readonly reasoningEffort?: string | undefined; } export function buildCodexDeveloperInstructions(interactionMode: ProviderInteractionMode): string { diff --git a/apps/server/src/provider/CodexInstallation.test.ts b/apps/server/src/provider/CodexInstallation.test.ts index 54cc2aeb41c1..849b3c928af8 100644 --- a/apps/server/src/provider/CodexInstallation.test.ts +++ b/apps/server/src/provider/CodexInstallation.test.ts @@ -2,23 +2,19 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ModelManifest from "./ModelManifest.ts"; import { expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; -import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Exit from "effect/Exit"; import * as Queue from "effect/Queue"; import * as Scope from "effect/Scope"; -import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/http"; -import * as TestClock from "effect/testing/TestClock"; -import { ChildProcessSpawner } from "effect/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as CodexInstallation from "./CodexInstallation.ts"; const archive = Buffer.from( @@ -47,7 +43,7 @@ const makeHarness = Effect.fn("test.makeCodexInstallation")(function* ( const baseDir = input.baseDir ?? (yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-install-test-" })); const localDirectory = `${baseDir}/local`; - const localBinaryPath = `${localDirectory}/codex`; + const localBinaryPath = `${localDirectory}/codex${input.platform === "win32" ? ".exe" : ""}`; const probeLog = `${baseDir}/local-probes.txt`; if (input.local) { yield* fs.makeDirectory(localDirectory, { recursive: true }); @@ -190,17 +186,21 @@ it.effect.each([ expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("falls back to a managed download when the reused local executable disappears", () => - Effect.gen(function* () { - const h = yield* makeHarness({ local: { version: "0.156.1" } }); - expect((yield* h.installation.resolve()).source).toBe("local"); - yield* h.fs.remove(h.localBinaryPath); - yield* h.installation.start; - expect((yield* terminalState(h.installation)).phase).toBe("succeeded"); - expect((yield* h.installation.resolve()).source).toBe("managed"); - expect(h.downloads()).toBe(1); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); + +const compileNative = Effect.fn("test.compileNative")(function* ( + executablePath: string, + source: string, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const compiler = yield* spawner.spawn( + ChildProcess.make("cc", ["-x", "c", "-", "-o", executablePath], { + stdin: Stream.make(new TextEncoder().encode(source)), + stdout: "inherit", + stderr: "inherit", + }), + ); + expect(yield* compiler.exitCode).toBe(0); +}, Effect.scoped); it.effect("rechecks compatibility after the local executable is replaced", () => Effect.gen(function* () { @@ -263,16 +263,38 @@ it.effect("uses bundled Codex compatibility when the remote manifest omits its p }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("preserves the invoked name of version-manager launcher symlinks", () => +it.effect.each([ + { kind: "script", name: "volta-shim" }, + { kind: "native", name: "volta-shim" }, + { kind: "native", name: "mise" }, + { kind: "native", name: "aqua-proxy" }, +])("preserves the invoked name of $kind $name launcher symlinks", ({ kind, name }) => Effect.gen(function* () { const h = yield* makeHarness({ local: { version: "0.156.1" } }); - const launcher = `${h.baseDir}/launcher`; - yield* h.fs.writeFileString( - launcher, - '#!/bin/sh\ncase "$0" in */codex) ;; *) exit 1;; esac\ncase "$1" in --version) printf "codex-cli 0.156.1\\n";; app-server) exit 0;; esac\n', - { mode: 0o755 }, - ); - yield* h.fs.remove(h.localBinaryPath); + const launcher = `${h.baseDir}/${name}`; + if (kind === "native") { + const payload = `${h.baseDir}/codex.js`; + yield* h.fs.rename(h.localBinaryPath, payload); + yield* compileNative( + launcher, + `#include +#include +int main(int argc, char **argv) { + const char *name = strrchr(argv[0], '/'); + if (strcmp(name ? name + 1 : argv[0], "codex") != 0) return 1; + execv(${JSON.stringify(payload)}, argv); + return 1; +} +`, + ); + } else { + yield* h.fs.writeFileString( + launcher, + '#!/bin/sh\ncase "$0" in */codex) ;; *) exit 1;; esac\ncase "$1" in --version) printf "codex-cli 0.156.1\\n";; app-server) exit 0;; esac\n', + { mode: 0o755 }, + ); + yield* h.fs.remove(h.localBinaryPath); + } yield* h.fs.symlink(launcher, h.localBinaryPath); expect(yield* h.installation.start).toMatchObject({ source: "local", phase: "succeeded" }); expect((yield* h.installation.resolve()).executablePath).toBe(h.localBinaryPath); @@ -397,119 +419,4 @@ it.effect("keeps an activated runtime when a later update fails verification", ( }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it("publishes complete packages for all supported platforms", () => { - for (const platform of ["darwin", "linux", "win32"] as const) - for (const arch of ["x64", "arm64"]) - expect(CodexInstallation.resolveCodexReleaseAsset(platform, arch)?.url).toContain( - "codex-package-", - ); - expect(CodexInstallation.resolveCodexReleaseAsset("linux", "riscv64")).toBeNull(); -}); -// Live clock: staleness compares real filesystem mtimes. -it.live("startup removes only stale staging directories", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-install-test-" }); - const managed = `${baseDir}/tools/codex`; - const stale = ((yield* Clock.currentTimeMillis) - 25 * 60 * 60_000) / 1000; - for (const name of [".install-stale", ".active-stale", ".install-live"]) { - yield* fs.makeDirectory(`${managed}/${name}`, { recursive: true }); - } - yield* fs.utimes(`${managed}/.install-stale`, stale, stale); - yield* fs.utimes(`${managed}/.active-stale`, stale, stale); - const h = yield* makeHarness({ baseDir }); - expect(yield* fs.readDirectory(managed)).toEqual([".install-live"]); - expect((yield* h.installation.state).canRemove).toBe(true); - - yield* fs.remove(`${managed}/.install-live`, { recursive: true }); - yield* fs.makeDirectory(`${managed}/.install-stale`); - yield* fs.utimes(`${managed}/.install-stale`, stale, stale); - const fresh = yield* makeHarness({ baseDir }); - expect(yield* fs.readDirectory(managed)).toEqual([]); - expect((yield* fresh.installation.state).canRemove).toBe(false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -const processHandle = (stdout: string) => - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.make(new TextEncoder().encode(stdout)), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); -const windowsPackage: Record = { - "bin/codex.exe": "codex", - "bin/codex-code-mode-host.exe": "host", - "codex-path/rg.exe": "rg", - "codex-package.json": JSON.stringify({ - layoutVersion: 1, - version: asset.version, - target: asset.target, - entrypoint: "bin/codex.exe", - }), -}; // Stands in for Windows' bsdtar: CRLF listings, and extraction writes the Windows layout. -const makeWindowsTar = (fs: FileSystem.FileSystem, commands: Array) => - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - const { command: executable, args } = command as unknown as { - readonly command: string; - readonly args: ReadonlyArray; - }; - commands.push(executable); - const names = Object.keys(windowsPackage); - if (args[0] === "-tzf") return processHandle(`${names.join("\r\n")}\r\n`); - if (args[0] === "-tvzf") - return processHandle(`${names.map((name) => `-rwxr-xr-x 0 0 ${name}`).join("\r\n")}\r\n`); - const runtime = args[3] ?? ""; - for (const [name, contents] of Object.entries(windowsPackage)) { - yield* fs.makeDirectory(`${runtime}/${name.split("/").slice(0, -1).join("/")}`, { - recursive: true, - }); - yield* fs.writeFileString(`${runtime}/${name}`, contents); - } - return processHandle(""); - }).pipe(Effect.orDie), - ); - -it.effect("installs on Windows via System32 tar with CRLF output and a locked runtime", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const commands: Array = []; - const h = yield* makeHarness({ - platform: "win32", - spawner: makeWindowsTar(fs, commands), - runtimeRenameFailures: 3, - }); - yield* h.installation.start; - // Each failed attempt has scheduled its retry before the test fiber resumes. - for (let attempt = 0; attempt < 3; attempt++) { - yield* Queue.take(h.failedRenames); - yield* TestClock.adjust("100 millis"); - } - expect(yield* terminalState(h.installation)).toMatchObject({ phase: "succeeded" }); - expect(commands).toEqual([ - "C:\\Windows\\System32\\tar.exe", - "C:\\Windows\\System32\\tar.exe", - "C:\\Windows\\System32\\tar.exe", - ]); - const path = yield* Path.Path; - expect((yield* h.installation.resolve()).executablePath).toBe( - path.join(h.baseDir, "tools", "codex", "0.156.1", "bin", "codex.exe"), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); -it.effect("does not retry a failed runtime rename outside Windows", () => - Effect.gen(function* () { - const h = yield* makeHarness({ runtimeRenameFailures: 1 }); - yield* h.installation.start; - expect((yield* terminalState(h.installation)).phase).toBe("failed"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); diff --git a/apps/server/src/provider/CodexInstallation.ts b/apps/server/src/provider/CodexInstallation.ts index 31b62f0e26b1..e481f95e3c57 100644 --- a/apps/server/src/provider/CodexInstallation.ts +++ b/apps/server/src/provider/CodexInstallation.ts @@ -96,7 +96,7 @@ const RELEASES: Readonly> = { archiveBytes: 153839991, }, }; -export const resolveCodexReleaseAsset = (platform: NodeJS.Platform, arch: string) => +const resolveCodexReleaseAsset = (platform: NodeJS.Platform, arch: string) => RELEASES[`${platform}-${arch}`] ?? null; export class CodexInstallationError extends Schema.TaggedError()( "CodexInstallationError", @@ -329,6 +329,47 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function ); return output; }, Effect.scoped); + // Native Codex needs its adjacent host; launchers resolve their own payloads. + const hasCodeModeHost = Effect.fn("CodexInstallation.hasCodeModeHost")( + function* (realExecutablePath: string) { + if (/^(?:volta-shim|mise|aqua-proxy)(?:\.exe)?$/iu.test(path.basename(realExecutablePath))) + return true; + if (platform === "win32") { + if (path.extname(realExecutablePath).toLowerCase() !== ".exe") return true; + for (const extension of [".bunx", ".shim"]) { + const marker = yield* fs + .stat(realExecutablePath.slice(0, -4) + extension) + .pipe(Effect.option); + if ( + Option.isSome(marker) && + marker.value.type === "File" && + Number(marker.value.size) > 0 + ) + return true; + } + } else { + const header = yield* fs.open(realExecutablePath, { flag: "r" }).pipe( + Effect.flatMap((file) => file.readAlloc(2)), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(header) && header.value[0] === 0x23 && header.value[1] === 0x21) + return true; + } + const info = yield* fs.stat( + path.join( + path.dirname(realExecutablePath), + `codex-code-mode-host${platform === "win32" ? ".exe" : ""}`, + ), + ); + return ( + info.type === "File" && + Number(info.size) > 0 && + (platform === "win32" || (info.mode & 0o111) !== 0) + ); + }, + Effect.scoped, + Effect.orElseSucceed(() => false), + ); const localCache = new Map(); const resolveLocal = Effect.fn("CodexInstallation.resolveLocal")( function* () { @@ -347,6 +388,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function ) ) return null; + if (!(yield* hasCodeModeHost(realExecutablePath))) return null; const info = yield* fs.stat(executablePath); const fingerprint = `${realExecutablePath}:${info.size}:${Option.getOrUndefined(info.mtime)?.getTime()}:${Option.getOrUndefined(info.ino)}`; const cached = localCache.get(executablePath); diff --git a/apps/server/src/provider/CodexManagedErrors.ts b/apps/server/src/provider/CodexManagedErrors.ts index 9c8fe26bbb6e..f2ee114c74fd 100644 --- a/apps/server/src/provider/CodexManagedErrors.ts +++ b/apps/server/src/provider/CodexManagedErrors.ts @@ -9,6 +9,11 @@ const legacyFailures = { "Your ChatGPT usage limit was reached. Check ChatGPT Usage settings for your available allowance.", revoke: false, }, + subscription_sharing_workplace_individual_usage_limit_exceeded: { + message: + "You reached your workspace's limit for using ChatGPT in other apps. Ask your workspace admin to increase it.", + revoke: false, + }, subscription_sharing_usage_unavailable: { message: "ChatGPT usage is temporarily unavailable. Try again shortly.", revoke: false, diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index 69d27f1377b6..8034e910d67a 100644 --- a/apps/server/src/provider/CodexManagedRuntime.test.ts +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -1,5 +1,4 @@ // @effect-diagnostics nodeBuiltinImport:off - checks the host's default Codex directory without writing to it. -import * as NodeOS from "node:os"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { CodexSettings, EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; @@ -18,36 +17,9 @@ import * as CodexInstallation from "./CodexInstallation.ts"; import { makeCodexManagedRuntime } from "./CodexManagedRuntime.ts"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; import { codexAppServerArgs } from "./codexLaunchArgs.ts"; -import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts"; const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const decodeSettings = Schema.decodeSync(CodexSettings); -it.effect("managed home defaults to the global Codex home and honors configured home paths", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const primary = yield* resolveManagedCodexHomeLayout( - "/t3-state", - ProviderInstanceId.make("codex"), - decodeSettings({}), - ); - assert.equal(primary.sharedHomePath, path.join(NodeOS.homedir(), ".codex")); - assert.equal(primary.mode, "direct"); - const additional = yield* resolveManagedCodexHomeLayout( - "/t3-state", - ProviderInstanceId.make("codex-work"), - decodeSettings({}), - ); - assert.equal(additional.sharedHomePath, primary.sharedHomePath); - assert.equal(additional.mode, "authOverlay"); - const configured = yield* resolveManagedCodexHomeLayout( - "/t3-state", - ProviderInstanceId.make("codex-work"), - decodeSettings({ homePath: "/custom/shared", shadowHomePath: "/custom/shadow" }), - ); - assert.equal(configured.sharedHomePath, "/custom/shared"); - assert.equal(configured.effectiveHomePath, "/custom/shadow"); - }).pipe(Effect.provide(NodeServices.layer)), -); it.effect.each( (["managed", "local"] as const).flatMap((source) => (["primary", "additional"] as const).map((account) => ({ source, account })), diff --git a/apps/server/src/provider/CodexMcpElicitation.test.ts b/apps/server/src/provider/CodexMcpElicitation.test.ts index c0655faf0d67..66dd7ddb6e7d 100644 --- a/apps/server/src/provider/CodexMcpElicitation.test.ts +++ b/apps/server/src/provider/CodexMcpElicitation.test.ts @@ -45,12 +45,6 @@ describe("Codex MCP elicitation approvals", () => { }); }); - it("extracts the app name from a Computer Use request without metadata", () => { - const { _meta, ...requestWithoutMetadata } = request; - - NodeAssert.equal(describeMcpElicitation(requestWithoutMetadata).appName, "Safari"); - }); - it("returns the accepted form option to Codex", () => { NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "accept"), { action: "accept", @@ -66,6 +60,7 @@ describe("Codex MCP elicitation approvals", () => { }); }); + // regression: #8058 show Codex app access approval prompts it("returns persistent approval in the MCP response", () => { NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptAlways"), { action: "accept", @@ -74,18 +69,6 @@ describe("Codex MCP elicitation approvals", () => { }); }); - it("returns rejection without form content", () => { - NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "decline"), { - action: "decline", - }); - }); - - it("returns cancellation without form content", () => { - NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "cancel"), { - action: "cancel", - }); - }); - it("supports boolean permanent-approval fields", () => { const booleanRequest = { ...request, diff --git a/apps/server/src/provider/CodexProvider.catalog.test.ts b/apps/server/src/provider/CodexProvider.catalog.test.ts deleted file mode 100644 index 9b76f4cb8363..000000000000 --- a/apps/server/src/provider/CodexProvider.catalog.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import { CodexSettings } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; - -import { checkCodexProviderStatus } from "./CodexProvider.ts"; - -/** - * Stand-in for Codex 0.162's app-server catalog behavior: - * - `model/list` answers from `CODEX_HOME/models_cache.json` while it is fresh. - * - A startup refresh runs before `initialize`, so it uses the originator set - * at spawn (the override env var, else the plain CLI's `codex_cli_rs`). - * - The backend grants Ultrafast only to entitled accounts on app originators. - */ -const FAKE_CODEX_APP_SERVER = String.raw`#!/usr/bin/env node -const fs = require("node:fs"); -const path = require("node:path"); -const readline = require("node:readline"); -const cachePath = path.join(process.env.CODEX_HOME, "models_cache.json"); -const originator = process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE ?? "codex_cli_rs"; -const ultrafast = process.env.FAKE_ULTRAFAST_ENTITLED === "1" && originator !== "codex_cli_rs"; -const model = (tiers) => ({ - id: "gpt-6.1-sol", model: "gpt-6.1-sol", displayName: "GPT-6.1 Sol", description: "", - hidden: false, isDefault: true, defaultReasoningEffort: "high", - supportedReasoningEfforts: [{ reasoningEffort: "high", description: "" }], - serviceTiers: tiers.map((id) => ({ id, name: id, description: "" })), -}); -setTimeout(() => { - const tiers = ultrafast ? ["priority", "ultrafast"] : ["priority"]; - fs.writeFileSync(cachePath, JSON.stringify({ fetched_at: new Date().toISOString(), models: [model(tiers)] })); -}, 300); -const write = (message) => process.stdout.write(JSON.stringify(message) + "\n"); -readline.createInterface({ input: process.stdin }).on("line", (line) => { - const { id, method } = JSON.parse(line); - if (id === undefined) return; - if (method === "initialize") { - write({ id, result: { userAgent: "codex/0.162.0", codexHome: process.env.CODEX_HOME, platformFamily: "unix", platformOs: "macos" } }); - } else if (method === "account/read") { - write({ id, result: { account: { type: "chatgpt", email: "test@example.com", planType: "promax" }, requiresOpenaiAuth: false } }); - } else if (method === "skills/list") { - write({ id, result: { data: [] } }); - } else if (method === "model/list") { - write({ id, result: { data: JSON.parse(fs.readFileSync(cachePath, "utf8")).models, nextCursor: null } }); - } else { - write({ id, error: { code: -32601, message: "unsupported" } }); - } -}); -`; - -const decodeCodexSettings = Schema.decodeSync(CodexSettings); - -const solServiceTiers = Effect.fn("solServiceTiers")(function* (ultrafastEntitled: boolean) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-catalog-" }); - const binaryPath = path.join(dir, "codex"); - yield* fs.writeFileString(binaryPath, FAKE_CODEX_APP_SERVER); - yield* fs.chmod(binaryPath, 0o755); - - yield* fs.writeFileString( - path.join(dir, "models_cache.json"), - JSON.stringify({ - fetched_at: DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { minutes: 1 })), - models: [ - { - id: "gpt-6.1-sol", - model: "gpt-6.1-sol", - displayName: "GPT-6.1 Sol", - description: "", - hidden: false, - isDefault: true, - defaultReasoningEffort: "high", - supportedReasoningEfforts: [{ reasoningEffort: "high", description: "" }], - serviceTiers: (ultrafastEntitled ? ["priority"] : ["priority", "ultrafast"]).map( - (id) => ({ id, name: id, description: "" }), - ), - }, - ], - }), - ); - - const { CODEX_INTERNAL_ORIGINATOR_OVERRIDE: _ambient, ...environment } = process.env; - const provider = yield* checkCodexProviderStatus( - decodeCodexSettings({ binaryPath, homePath: dir }), - undefined, - { ...environment, FAKE_ULTRAFAST_ENTITLED: ultrafastEntitled ? "1" : "0" }, - ); - const serviceTier = provider.models - .find((model) => model.slug === "gpt-6.1-sol") - ?.capabilities?.optionDescriptors?.find((descriptor) => descriptor.id === "serviceTier"); - return serviceTier?.type === "select" ? serviceTier.options.map((option) => option.id) : []; -}); - -it.layer(NodeServices.layer, { excludeTestServices: true })("Codex catalog discovery", (it) => { - it.effect("keeps Ultrafast when another Codex client wrote the shared catalog cache", () => - Effect.gen(function* () { - assert.deepStrictEqual(yield* solServiceTiers(true), ["default", "priority", "ultrafast"]); - }).pipe(Effect.scoped), - ); - - it.effect("drops Ultrafast once the account is no longer entitled to it", () => - Effect.gen(function* () { - assert.deepStrictEqual(yield* solServiceTiers(false), ["default", "priority"]); - }).pipe(Effect.scoped), - ); -}); diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 2c67b6f144db..30f63b21f318 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -128,15 +128,6 @@ it("marks the most preferred available model as default", () => { ); }); -it("prefers sol over terra when both are available", () => { - const models = applyPreferredCodexDefaultModel([ - { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, - { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, - ]); - - assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol"); -}); - it("ranks qualified Codex models while preserving their wire ids", () => { const models = applyPreferredCodexDefaultModel([ { @@ -154,15 +145,6 @@ it("ranks qualified Codex models while preserving their wire ids", () => { ); }); -it("keeps Codex's own default when no preferred model is available", () => { - const models = applyPreferredCodexDefaultModel([ - { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null }, - { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, - ]); - - assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); -}); - it("ignores custom models that shadow a preferred slug", () => { const models = applyPreferredCodexDefaultModel([ { slug: "gpt-5.6-sol", name: "gpt-5.6-sol", isCustom: true, capabilities: null }, diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index f4f123dc1a25..4a60f12891ee 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -67,7 +67,8 @@ type CodexRateLimitsProbe = | { readonly failure: string }; const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; -const CODEX_CATALOG_REFRESH_TIMEOUT = "3 seconds" as const; +const CODEX_CATALOG_REFRESH_TIMEOUT_MS = 3_000; +const CODEX_CONFIG_PROBE_TIMEOUT_MS = 3_000; const CODEX_CATALOG_REFRESH_POLL = "100 millis" as const; const CODEX_CLIENT_NAME = "T3 Code"; // Codex's probe does a full handshake plus account, models, and skills requests, which can @@ -75,6 +76,7 @@ const CODEX_CLIENT_NAME = "T3 Code"; // (most often reported on Windows). Give Codex its own longer budget instead of raising the // timeout for every provider. const CODEX_AUTH_PROBE_TIMEOUT_MS = 20_000; +const CODEX_DISCOVERY_MARGIN_MS = 250; const CODEX_PRESENTATION = { displayName: "Codex", @@ -86,7 +88,8 @@ export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; readonly rateLimits?: CodexRateLimitsProbe; readonly version: string | undefined; - readonly models: ReadonlyArray; + readonly models: ReadonlyArray | undefined; + readonly serviceTier?: string | undefined; readonly skills: ReadonlyArray; } @@ -160,12 +163,16 @@ function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) return account.email; } +export function codexDefaultReasoningEffortOverride(model: string): "medium" | undefined { + return codexModelFamily(model) === "gpt-6-astra" ? "medium" : undefined; +} + export function mapCodexModelCapabilities( model: CodexSchema.V2ModelListResponse__Model, ): ModelCapabilities { const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => reasoningEffort === - (codexModelFamily(model.model) === "gpt-6-astra" ? "medium" : model.defaultReasoningEffort) + (codexDefaultReasoningEffortOverride(model.model) ?? model.defaultReasoningEffort) ? { id: reasoningEffort, label: reasoningEffortLabel(reasoningEffort), @@ -265,6 +272,37 @@ export function applyPreferredCodexDefaultModel( }); } +function applyConfiguredCodexServiceTier( + models: ReadonlyArray, + serviceTier: string | undefined, +): ReadonlyArray { + if (!serviceTier) return models; + return models.map((model) => { + if (!model.capabilities?.optionDescriptors) return model; + return { + ...model, + capabilities: { + ...model.capabilities, + optionDescriptors: model.capabilities.optionDescriptors.map((descriptor) => { + if (descriptor.id !== "serviceTier" || descriptor.type !== "select") return descriptor; + const value = descriptor.options.find( + (option) => + option.id === serviceTier || (serviceTier === "fast" && option.id === "priority"), + )?.id; + if (!value) return descriptor; + return { + ...descriptor, + currentValue: value, + options: descriptor.options.map(({ isDefault: _isDefault, ...option }) => + option.id === value ? { ...option, isDefault: true } : option, + ), + }; + }), + }, + }; + }); +} + /** * Codex has no static default capability set, so a bare custom slug borrows * the first built-in's descriptors; an entry with its own capabilities keeps @@ -350,6 +388,19 @@ const readCodexModelsCacheFetchedAt = Effect.fn("readCodexModelsCacheFetchedAt") ); }); +const withCodexDiscoveryDeadline = Effect.fnUntraced(function* ( + effect: Effect.Effect, + deadlineMs: number, + timeoutMs = Infinity, +) { + const remainingMs = Math.min(timeoutMs, deadlineMs - DateTime.toEpochMillis(yield* DateTime.now)); + if (remainingMs <= 0) return undefined; + return yield* effect.pipe( + Effect.timeoutOption(Duration.millis(remainingMs)), + Effect.map(Option.getOrUndefined), + ); +}); + /** * Every Codex client on the machine shares that file, and the backend tailors * the catalog to each client's originator: the plain CLI does not get Sol @@ -360,22 +411,33 @@ const requestCodexChatGptModels = Effect.fn("requestCodexChatGptModels")(functio readonly client: CodexClient.CodexAppServerClient["Service"]; readonly codexHome: string; readonly spawnedAtMs: number; + readonly deadlineMs: number; }) { - const models = yield* requestAllCodexModels(input.client); - const refreshed = yield* readCodexModelsCacheFetchedAt(input.codexHome).pipe( - Effect.repeat({ - schedule: Schedule.spaced(CODEX_CATALOG_REFRESH_POLL), - until: (fetchedAtMs) => fetchedAtMs >= input.spawnedAtMs, - }), - Effect.timeoutOption(CODEX_CATALOG_REFRESH_TIMEOUT), + const models = yield* withCodexDiscoveryDeadline( + requestAllCodexModels(input.client), + input.deadlineMs, + ); + if (models === undefined) return undefined; + const refreshed = yield* withCodexDiscoveryDeadline( + readCodexModelsCacheFetchedAt(input.codexHome).pipe( + Effect.repeat({ + schedule: Schedule.spaced(CODEX_CATALOG_REFRESH_POLL), + until: (fetchedAtMs) => fetchedAtMs >= input.spawnedAtMs, + }), + ), + input.deadlineMs, + CODEX_CATALOG_REFRESH_TIMEOUT_MS, ); - if (Option.isNone(refreshed)) { + if (refreshed === undefined) { yield* Effect.logWarning( "Codex did not refresh its model catalog in time; using the cached catalog.", ); return models; } - return yield* requestAllCodexModels(input.client); + return ( + (yield* withCodexDiscoveryDeadline(requestAllCodexModels(input.client), input.deadlineMs)) ?? + models + ); }); /** @@ -460,7 +522,6 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun readonly homePath?: string; readonly launchArgs?: string; readonly cwd: string; - readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; readonly skipNativeUsage?: boolean; }) { @@ -475,33 +536,51 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun return { account: accountResponse, version, - models: appendCustomCodexModels([], input.customModels ?? []), + models: [], skills: [], } satisfies CodexAppServerProviderSnapshot; } - const [skillsResponse, models, rateLimits] = yield* Effect.all( + // Optional discovery must finish before the outer timeout can discard the account result. + const deadlineMs = spawnedAtMs + CODEX_AUTH_PROBE_TIMEOUT_MS - CODEX_DISCOVERY_MARGIN_MS; + const [skillsResponse, models, serviceTier, rateLimits] = yield* Effect.all( [ - client.request("skills/list", { - cwds: [input.cwd], - }), + withCodexDiscoveryDeadline(client.request("skills/list", { cwds: [input.cwd] }), deadlineMs), accountResponse.account?.type === "chatgpt" - ? requestCodexChatGptModels({ client, codexHome: initialize.codexHome, spawnedAtMs }) - : requestAllCodexModels(client), + ? requestCodexChatGptModels({ + client, + codexHome: initialize.codexHome, + spawnedAtMs, + deadlineMs, + }) + : withCodexDiscoveryDeadline(requestAllCodexModels(client), deadlineMs), + // The shared catalog must exclude config from the server's launch project. + withCodexDiscoveryDeadline( + client.request("config/read", { includeLayers: false }), + deadlineMs, + CODEX_CONFIG_PROBE_TIMEOUT_MS, + ).pipe( + Effect.map((response) => response?.config.service_tier ?? undefined), + Effect.catch(() => Effect.logDebug("Codex config read failed.").pipe(Effect.as(undefined))), + ), + // Usage is an enrichment: a failure or a slow answer degrades to "no + // usage this probe" rather than costing the account and models. input.skipNativeUsage ? Effect.succeed(undefined) - : client.request("account/rateLimits/read", null).pipe( - Effect.map((response): CodexRateLimitsProbe => ({ - snapshot: response.rateLimits, - rateLimitsByLimitId: response.rateLimitsByLimitId, - resetCredits: response.rateLimitResetCredits, - accountId: response.accountId, - })), - Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), - Effect.map( - Option.getOrElse((): CodexRateLimitsProbe => ({ - failure: "Codex did not answer the usage request.", - })), + : withCodexDiscoveryDeadline( + client.request("account/rateLimits/read", null), + deadlineMs, + RATE_LIMITS_PROBE_TIMEOUT_MS, + ).pipe( + Effect.map((response): CodexRateLimitsProbe => + response + ? { + snapshot: response.rateLimits, + rateLimitsByLimitId: response.rateLimitsByLimitId, + resetCredits: response.rateLimitResetCredits, + accountId: response.accountId, + } + : { failure: "Codex did not answer the usage request." }, ), Effect.catch((error) => Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( @@ -517,10 +596,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun account: accountResponse, ...(rateLimits ? { rateLimits } : {}), version, - models: applyPreferredCodexDefaultModel( - appendCustomCodexModels(models, input.customModels ?? []), - ), - skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), + models, + serviceTier, + skills: skillsResponse ? parseCodexSkillsListResponse(skillsResponse, input.cwd) : [], } satisfies CodexAppServerProviderSnapshot; }); @@ -539,33 +617,16 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => appendCustomCodexModels([], codexSettings.customModels); -const makePendingCodexProvider = ( +const makePendingCodexProvider = Effect.fn("CodexProvider.makePendingCodexProvider")(function* ( codexSettings: CodexSettings, -): Effect.Effect => - Effect.gen(function* () { - const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = emptyCodexModelsFromSettings(codexSettings); - - if (!codexSettings.enabled) { - return buildServerProvider({ - presentation: CODEX_PRESENTATION, - enabled: false, - checkedAt, - models, - skills: [], - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Codex is disabled in T3 Code settings.", - }, - }); - } +): Effect.fn.Return { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = emptyCodexModelsFromSettings(codexSettings); + if (!codexSettings.enabled) { return buildServerProvider({ presentation: CODEX_PRESENTATION, - enabled: true, + enabled: false, checkedAt, models, skills: [], @@ -574,10 +635,26 @@ const makePendingCodexProvider = ( version: null, status: "warning", auth: { status: "unknown" }, - message: "Codex provider status has not been checked in this session yet.", + message: "Codex is disabled in T3 Code settings.", }, }); + } + + return buildServerProvider({ + presentation: CODEX_PRESENTATION, + enabled: true, + checkedAt, + models, + skills: [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Codex provider status has not been checked in this session yet.", + }, }); +}); function accountProbeStatus({ account, rateLimits }: CodexAppServerProviderSnapshot): { readonly status: Exclude; @@ -618,7 +695,6 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu readonly homePath?: string; readonly launchArgs?: string; readonly cwd: string; - readonly customModels: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; readonly skipNativeUsage?: boolean; }) => Effect.Effect< @@ -659,12 +735,12 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu homePath: codexSettings.homePath, launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), cwd: process.cwd(), - customModels: codexSettings.customModels, environment: resolvedEnvironment, ...(managedAuth ? { skipNativeUsage: true } : {}), }).pipe( - Effect.scoped, + // Process teardown can outlast the work budget without discarding a completed snapshot. Effect.timeoutOption(Duration.millis(CODEX_AUTH_PROBE_TIMEOUT_MS)), + Effect.scoped, Effect.result, ); @@ -733,7 +809,12 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, - models: snapshot.models, + models: applyConfiguredCodexServiceTier( + applyPreferredCodexDefaultModel( + appendCustomCodexModels(snapshot.models ?? [], codexSettings.customModels), + ), + snapshot.serviceTier, + ), skills: snapshot.skills, slashCommands: [ COMPACT_SLASH_COMMAND, @@ -751,9 +832,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu probe: { installed: true, version: snapshot.version ?? null, - status: accountStatus.status, + status: snapshot.models === undefined ? "warning" : accountStatus.status, auth: accountStatus.auth, - ...(accountStatus.message ? { message: accountStatus.message } : {}), + ...(accountStatus.message + ? { message: accountStatus.message } + : snapshot.models === undefined + ? { message: "Codex did not return its model catalog in time." } + : {}), ...(managedAuth ? {} : { usageLimits }), }, }); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index b27db9ca86b9..74f4daa1323a 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -7,13 +7,10 @@ import { } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; -import * as Deferred from "effect/Deferred"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { HttpClient } from "effect/http"; @@ -272,52 +269,6 @@ const layerTest = ProviderHostLive.layer.pipe( ); it.layer(layerTest)("AntigravityDriver", (it) => { - it.effect.skipIf(windowsHost)( - "preserves the Node install message when starting a standalone provider", - () => - Effect.gen(function* () { - const h = yield* makeHarness(); - const error = yield* h.refresh().pipe(Effect.flip); - expect(error.detail).toContain("Install Node.js"); - expect(h.launches).toEqual([]); - }).pipe( - Effect.scoped, - Effect.provideService(HostProcess.IsExecutable, true), - Effect.provideService(HostProcess.Environment, { PATH: "" }), - ), - ); - - it.effect.skipIf(windowsHost)("does not launch a process for a disabled instance", () => - Effect.gen(function* () { - const h = yield* makeHarness({ enabled: false }); - const snapshot = yield* h.instance.snapshot.refresh; - expect(snapshot.status).toBe("disabled"); - expect(h.acquisitions).toEqual([]); - expect(h.launches).toEqual([]); - expect(yield* h.fs.exists(h.profileDirectory)).toBe(false); - }).pipe(Effect.scoped), - ); - - it.effect.skipIf(windowsHost)("refreshes models after slow process startup", () => - Effect.gen(function* () { - const h = yield* makeHarness(); - const entered = yield* Deferred.make(); - const ready = yield* Deferred.make(); - h.controls.beforeAcquire = Deferred.succeed(entered, undefined).pipe( - Effect.andThen(Deferred.await(ready)), - ); - const refresh = yield* h.refresh().pipe(Effect.forkScoped); - yield* Deferred.await(entered); - yield* TestClock.adjust("70 seconds"); - yield* Deferred.succeed(ready, undefined); - yield* Fiber.join(refresh); - const snapshot = yield* h.instance.snapshot.getSnapshot; - expect(snapshot.auth.status).toBe("authenticated"); - expect(snapshot.models.length).toBeGreaterThan(0); - yield* h.assertClosed; - }).pipe(Effect.scoped), - ); - it.effect.skipIf(windowsHost)( "refreshes through the selected executable and personal Google ACP", () => @@ -382,42 +333,6 @@ it.layer(layerTest)("AntigravityDriver", (it) => { }).pipe(Effect.scoped), ); - it.effect.skipIf(windowsHost)( - "authenticates with the configured API key method and labels the account by method", - () => - Effect.gen(function* () { - const h = yield* makeHarness({ - config: { authMethod: "gemini-api-key", apiKey: "fixture-gemini-key" }, - }); - yield* h.refresh(); - const snapshot = yield* h.instance.snapshot.getSnapshot; - expect(snapshot.auth).toMatchObject({ - status: "authenticated", - type: "gemini-api-key", - label: "Gemini API key", - }); - expect(snapshot.models.length).toBeGreaterThan(0); - const nativeLaunches = h.launches.filter((launch) => launch.harnessPath !== undefined); - expect(nativeLaunches.map((launch) => launch.geminiApiKey)).toEqual(["fixture-gemini-key"]); - const requests = yield* h.readRequests; - expect( - requests - .filter((request) => request.method === "auth/login") - .map((request) => request.params?.methodId), - ).toEqual(["gemini-api-key"]); - yield* h.assertClosed; - }).pipe(Effect.scoped), - ); - - it.effect.skipIf(windowsHost)("reports the missing credential before launching a process", () => - Effect.gen(function* () { - const h = yield* makeHarness({ config: { authMethod: "gemini-api-key" } }); - const error = yield* h.refresh().pipe(Effect.flip); - expect(error.detail).toContain("API key"); - expect(h.launches.filter((launch) => launch.harnessPath !== undefined)).toEqual([]); - }).pipe(Effect.scoped), - ); - it.effect.skipIf(windowsHost)( "closes refresh processes and clears account metadata when Google sign-in is required", () => @@ -438,30 +353,6 @@ it.layer(layerTest)("AntigravityDriver", (it) => { }).pipe(Effect.scoped), ); - it.effect.skipIf(windowsHost)( - "clears account metadata when a text helper needs Google sign-in", - () => - Effect.gen(function* () { - const h = yield* makeHarness(); - yield* h.refresh(); - h.controls.selected = h.signedOut; - const error = yield* h.instance.textGeneration - .generateThreadTitle({ - cwd: h.profileDirectory, - message: "Repair Google login", - modelSelection: { instanceId: h.instance.instanceId, model: "gemini-test-low" }, - }) - .pipe(Effect.flip); - expect(error._tag).toBe("TextGenerationError"); - const snapshot = yield* h.instance.snapshot.getSnapshot; - expect(snapshot.auth.status).toBe("unauthenticated"); - expect(snapshot.models).toEqual([]); - expect(snapshot.supportsTextGeneration).toBe(false); - expect(h.releases).toEqual([h.first.version, h.signedOut.version]); - yield* h.assertClosed; - }).pipe(Effect.scoped), - ); - it.effect.skipIf(windowsHost)("keeps the previous catalog when executable resolution fails", () => Effect.gen(function* () { const h = yield* makeHarness(); @@ -479,26 +370,6 @@ it.layer(layerTest)("AntigravityDriver", (it) => { }).pipe(Effect.scoped), ); - it.effect.skipIf(windowsHost)( - "gives each process its own temp directory and removes it when the process closes", - () => - Effect.gen(function* () { - const h = yield* makeHarness(); - const tempRoot = h.directories.runtimeTemp; - yield* h.refresh(); - yield* h.refresh(); - const directories = h.launches.flatMap((launch) => - launch.tempDirectory === undefined ? [] : [launch.tempDirectory], - ); - expect(directories).toHaveLength(2); - for (const directory of directories) { - expect(h.path.dirname(directory)).toBe(tempRoot); - } - expect(new Set(directories).size).toBe(2); - yield* h.assertClosed; - }).pipe(Effect.scoped), - ); - it.effect.skipIf(windowsHost)( "removes runtime temp directories left by a previous server on create", () => @@ -536,15 +407,4 @@ it.layer(layerTest)("AntigravityDriver", (it) => { expect(yield* fs.exists(legacyRoot)).toBe(false); }).pipe(Effect.scoped), ); - - it.effect("probes through installation resolution without launching a process", () => - Effect.gen(function* () { - const h = yield* makeHarness({ enabled: true }); - const snapshot = yield* h.instance.snapshot.refresh; - expect(snapshot.installed).toBe(true); - expect(snapshot.version).toBe(h.first.version); - expect(h.launches).toEqual([]); - expect(h.acquisitions).toEqual([]); - }).pipe(Effect.scoped), - ); }); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 7a77aa6beb1a..0f42271c62d2 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -93,435 +93,436 @@ export const AntigravityDriver: ProviderDriver< configSchema: AntigravitySettings, defaultConfig: () => decodeSettings({}), usage: AntigravityUsage.antigravityUsageReader, - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const fileSystem = yield* FileSystem.FileSystem; - const httpClient = yield* HttpClient.HttpClient; - const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const host = yield* ProviderHost.ProviderHost; - const selfInvocation = yield* resolveSelfInvocation(); - const installation = yield* AntigravityInstallation.AntigravityInstallation; - const loggers = yield* ProviderEventLoggers.ProviderEventLoggers; - const modelCatalog = yield* ModelCatalog.ModelCatalog; - const currentCatalog = modelCatalog.current(DRIVER); - const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); - const settings = { ...config, enabled } satisfies AntigravitySettings; - const auth: AntigravityAuthConfig = { - authMethod: settings.authMethod, - apiKey: settings.apiKey, - gcpProject: settings.gcpProject, - gcpLocation: settings.gcpLocation, - }; - const authConfigIssue = antigravityAuthConfigIssue(auth); - const processEnvironment = yield* mergeProviderInstanceEnvironment(environment); - const userHome = resolveAntigravityUserHome( - yield* HostProcess.Platform, - processEnvironment, - yield* HostProcess.HomeDirectory, + create: Effect.fn("AntigravityDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const httpClient = yield* HttpClient.HttpClient; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const host = yield* ProviderHost.ProviderHost; + const selfInvocation = yield* resolveSelfInvocation(); + const installation = yield* AntigravityInstallation.AntigravityInstallation; + const loggers = yield* ProviderEventLoggers.ProviderEventLoggers; + const modelCatalog = yield* ModelCatalog.ModelCatalog; + const currentCatalog = modelCatalog.current(DRIVER); + const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; + const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); + const settings = { ...config, enabled } satisfies AntigravitySettings; + const auth: AntigravityAuthConfig = { + authMethod: settings.authMethod, + apiKey: settings.apiKey, + gcpProject: settings.gcpProject, + gcpLocation: settings.gcpLocation, + }; + const authConfigIssue = antigravityAuthConfigIssue(auth); + const processEnvironment = yield* mergeProviderInstanceEnvironment(environment); + const userHome = resolveAntigravityUserHome( + yield* HostProcess.Platform, + processEnvironment, + yield* HostProcess.HomeDirectory, + ); + const directories = yield* resolveAntigravityInstanceDirectories( + host.paths.stateDir, + instanceId, + ).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Could not resolve the Antigravity profile directory.", + cause, + }), + ), + ); + const profileDirectory = directories.profile; + for (const directory of [ + directories.runtimeTemp, + path.join(profileDirectory, "antigravity-acp", "tmp"), + ]) { + yield* removeAntigravityRuntimeTempDirs(directory).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), ); - const directories = yield* resolveAntigravityInstanceDirectories( - host.paths.stateDir, - instanceId, - ).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(Path.Path, path), + } + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const classifyModels = (draft: ServerProviderDraft) => + currentCatalog.pipe( + Effect.map((catalog) => stampIdentity(ModelCatalog.applyModelCatalog(draft, catalog))), + ); + + const makeRuntime = Effect.fn("AntigravityDriver.makeRuntime")(function* ( + input: Omit, + ): Effect.fn.Return< + AcpSessionRuntime.AcpSessionRuntime["Service"], + AcpError | ProviderSetupError, + Scope.Scope + > { + if (authConfigIssue !== null) { + return yield* new ProviderSetupError({ + instanceId, + operation: "configure", + detail: authConfigIssue, + }); + } + const executable = yield* installation.acquire(settings.binaryPath, processEnvironment).pipe( Effect.mapError( (cause) => - new ProviderDriverError({ - driver: DRIVER, + new ProviderSetupError({ instanceId, - detail: "Could not resolve the Antigravity profile directory.", + operation: "resolve", + detail: cause.detail, cause, }), ), ); - const profileDirectory = directories.profile; - for (const directory of [ - directories.runtimeTemp, - path.join(profileDirectory, "antigravity-acp", "tmp"), - ]) { - yield* removeAntigravityRuntimeTempDirs(directory).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - ); - } - const continuationIdentity = defaultProviderContinuationIdentity({ - driverKind: DRIVER, - instanceId, - }); - const stampIdentity = withInstanceIdentity({ - instanceId, - driverKind: DRIVER, - displayName, - accentColor, - continuationGroupKey: continuationIdentity.continuationKey, - }); - const classifyModels = (draft: ServerProviderDraft) => - currentCatalog.pipe( - Effect.map((catalog) => stampIdentity(ModelCatalog.applyModelCatalog(draft, catalog))), - ); - - const makeRuntime = Effect.fn("AntigravityDriver.makeRuntime")(function* ( - input: Omit, - ): Effect.fn.Return< - AcpSessionRuntime.AcpSessionRuntime["Service"], - AcpError | ProviderSetupError, - Scope.Scope - > { - if (authConfigIssue !== null) { - return yield* new ProviderSetupError({ - instanceId, - operation: "configure", - detail: authConfigIssue, - }); - } - const executable = yield* installation - .acquire(settings.binaryPath, processEnvironment) - .pipe( - Effect.mapError( - (cause) => - new ProviderSetupError({ - instanceId, - operation: "resolve", - detail: cause.detail, - cause, - }), - ), - ); - const profile = yield* prepareAntigravityProfile({ - profileDirectory, - baseEnv: processEnvironment, - auth, - userHome, - tempDirectory: directories.runtimeTemp, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.mapError((cause) => - isNodeRuntimeUnavailableError(cause.cause) - ? new ProviderSetupError({ - instanceId, - operation: "start", - detail: nodeRuntimeUnavailableMessage("Antigravity sign-in"), - cause, - }) - : cause, - ), - ); - // Each process unpacks into its own directory that dies with the - // runtime scope, after the child is killed. A shared directory would - // let one session's teardown delete files a sibling still reads. - // Removal is best effort: a handle can outlive the kill on Windows, - // and the sweep on the next driver start reclaims what is left. - const runtimeTempDirectory = yield* Effect.acquireRelease( - fileSystem.makeTempDirectory({ directory: profile.tempDirectory, prefix: "run-" }).pipe( - Effect.mapError( - (cause) => - new ProviderSetupError({ - instanceId, - operation: "start", - detail: "Could not create an Antigravity runtime temp directory.", - cause, - }), - ), + const profile = yield* prepareAntigravityProfile({ + profileDirectory, + baseEnv: processEnvironment, + auth, + userHome, + tempDirectory: directories.runtimeTemp, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError((cause) => + isNodeRuntimeUnavailableError(cause.cause) + ? new ProviderSetupError({ + instanceId, + operation: "start", + detail: nodeRuntimeUnavailableMessage("Antigravity sign-in"), + cause, + }) + : cause, + ), + ); + // Each process unpacks into its own directory that dies with the + // runtime scope, after the child is killed. A shared directory would + // let one session's teardown delete files a sibling still reads. + // Removal is best effort: a handle can outlive the kill on Windows, + // and the sweep on the next driver start reclaims what is left. + const runtimeTempDirectory = yield* Effect.acquireRelease( + fileSystem.makeTempDirectory({ directory: profile.tempDirectory, prefix: "run-" }).pipe( + Effect.mapError( + (cause) => + new ProviderSetupError({ + instanceId, + operation: "start", + detail: "Could not create an Antigravity runtime temp directory.", + cause, + }), ), - (directory) => - fileSystem - .remove(directory, { recursive: true, force: true }) - .pipe( - Effect.catch(() => - Effect.logWarning("Could not remove an Antigravity runtime temp directory."), - ), + ), + (directory) => + fileSystem + .remove(directory, { recursive: true, force: true }) + .pipe( + Effect.catch(() => + Effect.logWarning("Could not remove an Antigravity runtime temp directory."), ), - ); - const runtime = yield* makeAntigravityAcpRuntime({ - ...input, - authMethod: auth.authMethod, - childProcessSpawner: spawner, - spawn: buildAntigravityAcpSpawnInput({ - installation: executable, - profile, - cwd: input.cwd, - baseEnv: withAgentDeviceEnvironment(processEnvironment, input), - auth, - runtimeTempDirectory, - }), - }).pipe(Effect.provideService(Crypto.Crypto, crypto)); - return { - ...runtime, - start: () => - runtime - .start() - .pipe( - Effect.tapError((cause): Effect.Effect => - input.onAuthorizationUrl === undefined && isAntigravitySignInRequiredError(cause) - ? provider.onAuthRequired - : Effect.void, - ), + ), + ); + const runtime = yield* makeAntigravityAcpRuntime({ + ...input, + authMethod: auth.authMethod, + childProcessSpawner: spawner, + spawn: buildAntigravityAcpSpawnInput({ + installation: executable, + profile, + cwd: input.cwd, + baseEnv: withAgentDeviceEnvironment(processEnvironment, input), + auth, + runtimeTempDirectory, + }), + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + return { + ...runtime, + start: () => + runtime + .start() + .pipe( + Effect.tapError((cause): Effect.Effect => + input.onAuthorizationUrl === undefined && isAntigravitySignInRequiredError(cause) + ? provider.onAuthRequired + : Effect.void, ), - }; - }); - - const makeDisposableRuntime = Effect.fn("AntigravityDriver.makeDisposableRuntime")(function* ( - input: Pick, - ) { - const cwd = yield* fileSystem - .makeTempDirectoryScoped({ prefix: "t3-antigravity-setup-" }) - .pipe( - Effect.mapError( - () => - new ProviderSetupError({ - instanceId, - operation: "start", - detail: "Could not create an Antigravity setup workspace.", - }), ), - ); - let sessionId: string | undefined; - yield* Effect.addFinalizer(() => - removeAntigravitySessionFiles({ - profileDirectory, - sessionId, - cwd, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), + }; + }); + + const makeDisposableRuntime = Effect.fn("AntigravityDriver.makeDisposableRuntime")(function* ( + input: Pick, + ) { + const cwd = yield* fileSystem + .makeTempDirectoryScoped({ prefix: "t3-antigravity-setup-" }) + .pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId, + operation: "start", + detail: "Could not create an Antigravity setup workspace.", + }), ), ); - const runtime = yield* makeRuntime({ + let sessionId: string | undefined; + yield* Effect.addFinalizer(() => + removeAntigravitySessionFiles({ + profileDirectory, + sessionId, cwd, - clientInfo: { name: "t3-code-provider-setup", version: "0.0.0" }, - mcpServers: [], - ...(input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}), - }); - return { - ...runtime, - start: () => - runtime.start().pipe( - Effect.tap((started) => - Effect.sync(() => { - sessionId = started.sessionId; - }), - ), - ), - }; - }); - - const publishCatalog = ( - started: AcpSessionRuntime.AcpSessionRuntimeStartResult, - runtime: Pick, - ): Effect.Effect => - Effect.gen(function* () { - yield* provider.onSessionStarted(started); - yield* Stream.runForEach(runtime.getEvents(), (event) => { - if (event._tag === "EventStreamBarrier") { - return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid); - } - if (event._tag === "ConfigOptionsUpdated") { - return provider.onConfigOptionsUpdated(event.configOptions); - } - return event._tag === "AvailableCommandsUpdated" - ? provider.onAvailableCommands(event.availableCommands) - : Effect.void; - }).pipe(Effect.forkScoped); - yield* runtime.drainEvents; - }).pipe(Effect.scoped); - - const authFlow: AntigravityAuth = yield* makeAntigravityAuth({ - instanceId, - makeRuntime: makeDisposableRuntime, - onAuthenticated: publishCatalog, - onSignedOut: Effect.suspend(() => provider.onSignedOut), - usesBrowser: antigravityAuthUsesBrowser(auth.authMethod), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); + const runtime = yield* makeRuntime({ + cwd, + clientInfo: { name: "t3-code-provider-setup", version: "0.0.0" }, + mcpServers: [], + ...(input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}), }); + return { + ...runtime, + start: () => + runtime.start().pipe( + Effect.tap((started) => + Effect.sync(() => { + sessionId = started.sessionId; + }), + ), + ), + }; + }); - // The probe must not spawn. The agent is a PyInstaller one-file bundle - // that unpacks about 1 GB per launch, and the health check runs every - // minute. - const probe = Effect.gen(function* () { - yield* modelCatalog.refreshInBackground; - if (authConfigIssue !== null) { - return yield* new ProviderSetupError({ - instanceId, - operation: "configure", - detail: authConfigIssue, - }); + const publishCatalog = Effect.fn("AntigravityDriver.publishCatalog")(function* ( + started: AcpSessionRuntime.AcpSessionRuntimeStartResult, + runtime: Pick, + ) { + yield* provider.onSessionStarted(started); + yield* Stream.runForEach(runtime.getEvents(), (event) => { + if (event._tag === "EventStreamBarrier") { + return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid); } - const executable = yield* installation - .resolve(settings.binaryPath, processEnvironment) - .pipe( - Effect.mapError( - (cause) => - new ProviderSetupError({ - instanceId, - operation: "resolve", - detail: cause.detail, - cause, - }), - ), - ); - return { - protocolVersion: 1, - agentCapabilities: { - loadSession: true, - promptCapabilities: { image: true, audio: true, embeddedContext: true }, - sessionCapabilities: { list: {}, resume: {} }, - }, - authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], - agentInfo: { - name: "antigravity-acp", - title: "Google Antigravity", - version: executable.version ?? "unknown", - }, - }; - }); + if (event._tag === "ConfigOptionsUpdated") { + return provider.onConfigOptionsUpdated(event.configOptions); + } + return event._tag === "AvailableCommandsUpdated" + ? provider.onAvailableCommands(event.availableCommands) + : Effect.void; + }).pipe(Effect.forkScoped); + yield* runtime.drainEvents; + }, Effect.scoped); - const usage = yield* makeAntigravityUsageProbe({ - profileDirectory, - authMethod: auth.authMethod, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(HttpClient.HttpClient, httpClient), - ); - const provider = yield* makeAntigravityProvider(settings, { - stampIdentity: classifyModels, - probe, - probeUsage: usage.probe, - clearUsage: usage.clear, - auth: { type: auth.authMethod, label: antigravityAuthLabel(auth.authMethod) }, - supportsTextGeneration: isAntigravityTextGenerationAvailable(profileDirectory).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.orElseSucceed(() => false), - ), - }).pipe( + const authFlow: AntigravityAuth = yield* makeAntigravityAuth({ + instanceId, + makeRuntime: makeDisposableRuntime, + onAuthenticated: publishCatalog, + onSignedOut: Effect.suspend(() => provider.onSignedOut), + usesBrowser: antigravityAuthUsesBrowser(auth.authMethod), + }); + + // The probe must not spawn. The agent is a PyInstaller one-file bundle + // that unpacks about 1 GB per launch, and the health check runs every + // minute. + const probe = Effect.gen(function* () { + yield* modelCatalog.refreshInBackground; + if (authConfigIssue !== null) { + return yield* new ProviderSetupError({ + instanceId, + operation: "configure", + detail: authConfigIssue, + }); + } + const executable = yield* installation.resolve(settings.binaryPath, processEnvironment).pipe( Effect.mapError( (cause) => - new ProviderDriverError({ - driver: DRIVER, + new ProviderSetupError({ instanceId, - detail: "Could not prepare the Antigravity provider status.", + operation: "resolve", + detail: cause.detail, cause, }), ), ); - const defaultModel = currentCatalog.pipe(Effect.map((catalog) => catalog?.defaultChatModel)); - const orchestrationAdapter = yield* makeAntigravityAdapterV2({ - instanceId, - selfInvocation, - makeRuntime, - withProcess: authFlow.withProcess, - defaultModel, - onSessionStarted: provider.onSessionStarted, - onSessionEvent: (event) => { - if (event._tag === "ConfigOptionsUpdated") { - return provider.onConfigOptionsUpdated(event.configOptions); - } - return event._tag === "AvailableCommandsUpdated" - ? provider.onAvailableCommands(event.availableCommands) - : Effect.void; + return { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, + sessionCapabilities: { list: {}, resume: {} }, }, - continuationRequests, - nativeLogging: (threadId) => - makeNativeLogger({ - nativeEventLogger: loggers.native, - provider: DRIVER, - threadId, - }), - }); - const textGeneration = yield* makeAntigravityTextGeneration({ - profileDirectory, - defaultModel, - withProcess: authFlow.withProcess, - makeRuntime: (cwd) => - makeRuntime({ - cwd, - clientInfo: { name: "t3-code-text", version: "0.0.0" }, - mcpServers: [], - }), - }); - - const refreshModels = Effect.fn("AntigravityDriver.refreshModels")( - function* () { - const processScope = yield* Scope.make(); - yield* Effect.addFinalizer((exit) => Scope.close(processScope, exit)); - yield* authFlow - .withProcess( - Scope.close(processScope, Exit.void), - Effect.gen(function* () { - const runtime = yield* makeDisposableRuntime({}); - const started = yield* runtime.start(); - yield* publishCatalog(started, runtime); - }), - ) - .pipe(Effect.provideService(Scope.Scope, processScope)); + authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], + agentInfo: { + name: "antigravity-acp", + title: "Google Antigravity", + version: executable.version ?? "unknown", }, - Effect.scoped, - Effect.timeoutOrElse({ - duration: "90 seconds", - orElse: () => - Effect.fail( - new ProviderDriverError({ - driver: DRIVER, - instanceId, - detail: "Antigravity model refresh timed out. Try again or check Google sign-in.", - }), - ), + }; + }); + + const usage = yield* makeAntigravityUsageProbe({ + profileDirectory, + authMethod: auth.authMethod, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + const provider = yield* makeAntigravityProvider(settings, { + stampIdentity: classifyModels, + probe, + probeUsage: usage.probe, + clearUsage: usage.clear, + auth: { type: auth.authMethod, label: antigravityAuthLabel(auth.authMethod) }, + supportsTextGeneration: isAntigravityTextGenerationAvailable(profileDirectory).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.orElseSucceed(() => false), + ), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Could not prepare the Antigravity provider status.", + cause, + }), + ), + ); + const defaultModel = currentCatalog.pipe(Effect.map((catalog) => catalog?.defaultChatModel)); + const orchestrationAdapter = yield* makeAntigravityAdapterV2({ + instanceId, + selfInvocation, + makeRuntime, + withProcess: authFlow.withProcess, + defaultModel, + onSessionStarted: provider.onSessionStarted, + onSessionEvent: (event) => { + if (event._tag === "ConfigOptionsUpdated") { + return provider.onConfigOptionsUpdated(event.configOptions); + } + return event._tag === "AvailableCommandsUpdated" + ? provider.onAvailableCommands(event.availableCommands) + : Effect.void; + }, + continuationRequests, + nativeLogging: (threadId) => + makeNativeLogger({ + nativeEventLogger: loggers.native, + provider: DRIVER, + threadId, }), - Effect.tapError((cause) => - isAntigravitySignInRequiredError(cause) ? provider.onAuthRequired : Effect.void, - ), - Effect.mapError((cause) => - cause._tag === "ProviderDriverError" - ? cause - : new ProviderDriverError({ - driver: DRIVER, - instanceId, - detail: isAntigravitySignInRequiredError(cause) - ? "Sign in to Antigravity in provider settings before refreshing models." - : cause._tag === "ProviderSetupError" && - (cause.operation === "configure" || cause.operation === "start") - ? cause.detail - : "Could not refresh Antigravity models. The previous model list is unchanged.", - cause, - }), - ), - ); + }); + const textGeneration = yield* makeAntigravityTextGeneration({ + profileDirectory, + defaultModel, + withProcess: authFlow.withProcess, + makeRuntime: (cwd) => + makeRuntime({ + cwd, + clientInfo: { name: "t3-code-text", version: "0.0.0" }, + mcpServers: [], + }), + }); - return { - instanceId, - driverKind: DRIVER, - continuationIdentity, - displayName, - accentColor, - enabled, - snapshot: provider.snapshot, - snapshotForCwd: (cwd) => - !enabled - ? provider.snapshot.getSnapshot - : discoverAntigravitySkills({ cwd, userHome }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.flatMap((skills) => provider.snapshotForCwd(cwd, skills)), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER, - instanceId, - detail: "Could not read Antigravity workspace skills.", - cause, - }), - ), + const refreshModels = Effect.fn("AntigravityDriver.refreshModels")( + function* () { + const processScope = yield* Scope.make(); + yield* Effect.addFinalizer((exit) => Scope.close(processScope, exit)); + yield* authFlow + .withProcess( + Scope.close(processScope, Exit.void), + Effect.gen(function* () { + const runtime = yield* makeDisposableRuntime({}); + const started = yield* runtime.start(); + yield* publishCatalog(started, runtime); + }), + ) + .pipe(Effect.provideService(Scope.Scope, processScope)); + }, + Effect.scoped, + Effect.timeoutOrElse({ + duration: "90 seconds", + orElse: () => + Effect.fail( + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Antigravity model refresh timed out. Try again or check Google sign-in.", + }), + ), + }), + Effect.tapError((cause) => + isAntigravitySignInRequiredError(cause) ? provider.onAuthRequired : Effect.void, + ), + Effect.mapError((cause) => + cause._tag === "ProviderDriverError" + ? cause + : new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: isAntigravitySignInRequiredError(cause) + ? "Sign in to Antigravity in provider settings before refreshing models." + : cause._tag === "ProviderSetupError" && + (cause.operation === "configure" || cause.operation === "start") + ? cause.detail + : "Could not refresh Antigravity models. The previous model list is unchanged.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot: provider.snapshot, + snapshotForCwd: (cwd) => + !enabled + ? provider.snapshot.getSnapshot + : discoverAntigravitySkills({ cwd, userHome }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.flatMap((skills) => provider.snapshotForCwd(cwd, skills)), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Could not read Antigravity workspace skills.", + cause, + }), ), - orchestrationAdapter, - textGeneration, - auth: authFlow.controller, - refreshModels, - } satisfies ProviderInstance; - }), + ), + orchestrationAdapter, + textGeneration, + auth: authFlow.controller, + refreshModels, + } satisfies ProviderInstance; + }), }; diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts index 4293e800f56b..5060d8a24e5e 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; +import { discoverAntigravitySkills } from "./AntigravitySkills.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) { @@ -93,34 +93,6 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { }), ); - it.effect("returns no skills when the native roots are missing", () => - Effect.gen(function* () { - const input = yield* makeWorkspace(); - assert.deepEqual(yield* discoverAntigravitySkills(input), []); - }), - ); - - it.effect("discovers skills from the legacy workspace root", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const input = yield* makeWorkspace(); - const skillPath = yield* writeSkill( - path.join(input.cwd, ".agent", "skills", "review"), - "---\nname: review\ndescription: Review changes.\n---\n", - ); - - assert.deepEqual(yield* discoverAntigravitySkills(input), [ - { - name: "review", - description: "Review changes.", - path: skillPath, - scope: "project", - enabled: true, - }, - ]); - }), - ); - it.effect("uses native root order for duplicate names", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -340,38 +312,3 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { }), ); }); - -it("resolves the home the agent expands ~ against", () => { - assert.equal( - resolveAntigravityUserHome( - "linux", - { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }, - "/home/fallback", - ), - "/home/user", - ); - assert.equal( - resolveAntigravityUserHome( - "win32", - { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }, - "/home/fallback", - ), - "C:\\Users\\user", - ); - assert.equal( - resolveAntigravityUserHome( - "win32", - { HOMEDRIVE: "D:", HOMEPATH: "\\Users\\alice" }, - "/home/fallback", - ), - "D:\\Users\\alice", - ); - assert.equal( - resolveAntigravityUserHome("darwin", { HOME: "/Users/a b " }, "/home/fallback"), - "/Users/a b ", - ); - assert.equal( - resolveAntigravityUserHome("darwin", { HOME: "" }, "/home/fallback"), - "/home/fallback", - ); -}); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 4e5fe8e10480..86832d53d311 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -113,288 +113,300 @@ export const ClaudeDriver: ProviderDriver decodeClaudeSettings({}), usage: claudeUsageReader, - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - // The server cwd is $HOME in packaged desktop builds, and `claude` - // wrappers that scope themselves to their cwd refuse to run there. - const { stateDir } = yield* ServerConfig.ServerConfig; - const httpClient = yield* HttpClient.HttpClient; - const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; - const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; - const catalogService = yield* ModelCatalog.ModelCatalog; - const modelCatalog = catalogService - .current(DRIVER_KIND) - .pipe(Effect.map(resolveClaudeModelCatalog)); - const processEnv = yield* mergeProviderInstanceEnvironment(environment); - const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ - driverKind: DRIVER_KIND, - instanceId, - }); - const effectiveConfig = { - ...config, - enabled, - binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), - } satisfies ClaudeSettings; - const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( - resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - ); - const continuationGroupKey = yield* makeClaudeContinuationGroupKey( - effectiveConfig, - processEnv, - ); - const configDir = yield* resolveClaudeHomePath(effectiveConfig, processEnv); - const accountConfigPath = yield* ClaudeResetCredits.claudeAccountConfigPath( - effectiveConfig.homePath.trim() || processEnv.CLAUDE_CONFIG_DIR?.trim() - ? configDir - : undefined, - ); - const stampIdentity = withInstanceIdentity({ + create: Effect.fn("ClaudeDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // The server cwd is $HOME in packaged desktop builds, and `claude` + // wrappers that scope themselves to their cwd refuse to run there. + const { stateDir } = yield* ServerConfig.ServerConfig; + const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; + const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const catalogService = yield* ModelCatalog.ModelCatalog; + const modelCatalog = catalogService + .current(DRIVER_KIND) + .pipe(Effect.map(resolveClaudeModelCatalog)); + const processEnv = yield* mergeProviderInstanceEnvironment(environment); + const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const effectiveConfig = { + ...config, + enabled, + binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), + } satisfies ClaudeSettings; + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); + const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig, processEnv); + const configDir = yield* resolveClaudeHomePath(effectiveConfig, processEnv); + const accountConfigPath = yield* ClaudeResetCredits.claudeAccountConfigPath( + effectiveConfig.homePath.trim() || processEnv.CLAUDE_CONFIG_DIR?.trim() + ? configDir + : undefined, + ); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey, + }); + + const scopedLimitNames = yield* makeClaudeScopedLimitNames; + const orchestrationAdapter = yield* createClaudeAdapterV2( + { instanceId, - driverKind: DRIVER_KIND, displayName, accentColor, - continuationGroupKey, - }); - - const scopedLimitNames = yield* makeClaudeScopedLimitNames; - const orchestrationAdapter = yield* createClaudeAdapterV2( - { - instanceId, - displayName, - accentColor, - environment, - enabled, - config, - }, - { scopedLimitNames, onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, - ).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Claude orchestration adapter.", - cause, - }), - ), - ); - const textGeneration = yield* makeClaudeTextGeneration( - effectiveConfig, - processEnv, - modelCatalog, - ); + environment, + enabled, + config, + }, + { scopedLimitNames, onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, + ).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Claude orchestration adapter.", + cause, + }), + ), + ); + const textGeneration = yield* makeClaudeTextGeneration( + effectiveConfig, + processEnv, + modelCatalog, + ); - // Per-instance capabilities cache: keyed on binary + resolved HOME so - // account-specific probes never share auth metadata across instances. - // A transient miss may reuse the last account once, but its usage windows keep their age. - const lastGoodCapabilities = yield* Ref.make(undefined); - const capabilitiesProbeCache = yield* Cache.makeWith( - () => - probeClaudeCapabilities(effectiveConfig, processEnv, stateDir).pipe( - Effect.flatMap((capabilities) => - ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath).pipe( - Effect.map((workspaceId) => ({ - _tag: "fresh" as const, - capabilities: - workspaceId === undefined ? capabilities : { ...capabilities, workspaceId }, - })), - ), + // Per-instance capabilities cache: keyed on binary + resolved HOME so + // account-specific probes never share auth metadata across instances. + // A transient miss may reuse the last account once, but its usage windows keep their age. + const lastGoodCapabilities = yield* Ref.make(undefined); + const capabilitiesProbeCache = yield* Cache.makeWith( + () => + probeClaudeCapabilities(effectiveConfig, processEnv, stateDir).pipe( + Effect.flatMap((capabilities) => + ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath).pipe( + Effect.map((workspaceId) => ({ + _tag: "fresh" as const, + capabilities: + workspaceId === undefined ? capabilities : { ...capabilities, workspaceId }, + })), ), - Effect.tap(({ capabilities }) => Ref.set(lastGoodCapabilities, capabilities)), - Effect.catch((error) => - Ref.getAndSet(lastGoodCapabilities, undefined).pipe( - Effect.flatMap((capabilities) => - capabilities === undefined - ? Effect.fail(error) - : ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath).pipe( - Effect.flatMap((workspaceId) => - workspaceId === capabilities.workspaceId - ? Effect.succeed({ - _tag: "fallback" as const, - capabilities: withoutUsage(capabilities), - }) - : Effect.fail(error), - ), + ), + Effect.tap(({ capabilities }) => Ref.set(lastGoodCapabilities, capabilities)), + Effect.catch((error) => + Ref.getAndSet(lastGoodCapabilities, undefined).pipe( + Effect.flatMap((capabilities) => + capabilities === undefined + ? Effect.fail(error) + : ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath).pipe( + Effect.flatMap((workspaceId) => + workspaceId === capabilities.workspaceId + ? Effect.succeed({ + _tag: "fallback" as const, + capabilities: withoutUsage(capabilities), + }) + : Effect.fail(error), ), - ), + ), ), ), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), ), - { - capacity: 1, - timeToLive: (exit) => - // Only a fresh probe earns the TTL; a failure or its fallback retries on the next check. - Exit.isSuccess(exit) && exit.value._tag === "fresh" - ? CAPABILITIES_PROBE_TTL - : Duration.zero, - }, - ); - const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey( - effectiveConfig, - stateDir, - processEnv, - ); - const resolveCapabilities = Cache.get(capabilitiesProbeCache, capabilitiesCacheKey).pipe( - Effect.map((result) => result.capabilities), - ); + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + { + capacity: 1, + timeToLive: (exit) => + // Only fresh usage earns the TTL; failures, fallbacks and usage-less probes retry. + Exit.isSuccess(exit) && + exit.value._tag === "fresh" && + exit.value.capabilities.usage !== undefined + ? CAPABILITIES_PROBE_TTL + : Duration.zero, + }, + ); + const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey( + effectiveConfig, + stateDir, + processEnv, + ); + const resolveCapabilities = Cache.get(capabilitiesProbeCache, capabilitiesCacheKey).pipe( + Effect.map((result) => result.capabilities), + ); - const checkProvider = catalogService.refreshInBackground.pipe( - Effect.andThen( - modelCatalog.pipe( - Effect.flatMap((catalog) => - checkClaudeProviderStatus( - effectiveConfig, - () => resolveCapabilities, - processEnv, - stateDir, - catalog, - scopedLimitNames, - (version) => - ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - ), + const checkProvider = catalogService.refreshInBackground.pipe( + Effect.andThen( + modelCatalog.pipe( + Effect.flatMap((catalog) => + checkClaudeProviderStatus( + effectiveConfig, + () => resolveCapabilities, + processEnv, + stateDir, + catalog, + scopedLimitNames, + (version) => + ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), ), - Effect.map(stampIdentity), ), + Effect.tap((provider) => + provider.auth.status === "unauthenticated" + ? Cache.invalidateAll(capabilitiesProbeCache) + : Effect.void, + ), + Effect.map(stampIdentity), ), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ); + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); - const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); - const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance, - getSettings: snapshotSettings.getSettings, - streamSettings: snapshotSettings.streamSettings, - haveSettingsChanged: haveProviderSnapshotSettingsChanged, - initialSnapshot: (settings) => - modelCatalog.pipe( - Effect.flatMap((catalog) => makePendingClaudeProvider(settings.provider, catalog)), - Effect.map(stampIdentity), - ), - checkProvider, - enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => - resolveMaintenance().pipe( - Effect.flatMap((maintenanceCapabilities) => - enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - }), - ), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), - ), - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to build Claude snapshot: ${cause.message ?? String(cause)}`, - cause, + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + modelCatalog.pipe( + Effect.flatMap((catalog) => makePendingClaudeProvider(settings.provider, catalog)), + Effect.map(stampIdentity), + ), + checkProvider, + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, }), + ), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), - ); + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Claude snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); - // Same rules as Codex: serialised on the config directory that holds the - // login, one request id kept until Claude answers (a cooldown or rate - // limit is an answer), then a re-probe. - const consumeResetCredit: NonNullable = () => - Effect.gen(function* () { - const current = yield* snapshot.getSnapshot; - const grantId = current.usageLimits?.resetCredits?.nextCreditId; - if (!grantId || !current.version) return "noCredit" as const; - const version = current.version; - return yield* resetCreditCoordinator.redeem( - configDir, - (requestId) => - ClaudeResetCredits.consumeClaudeResetCredit({ - configDir, - accountConfigPath, - version, - grantId, - requestId, - }), - ClaudeResetCredits.isSettledClaudeResetCreditFailure, - ); - }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: - cause._tag === "ClaudeResetCreditError" - ? cause.message - : "Claude could not redeem the reset.", - cause, - }), - ), - Effect.tap((outcome) => - Effect.gen(function* () { - const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; - yield* Cache.invalidateAll(capabilitiesProbeCache); - const refreshed = yield* snapshot.refresh; - const after = refreshed.usageLimits?.checkedAt; - if ( - outcome === "reset" && - (after === undefined || - after === before || - refreshed.usageLimits?.unavailable?.reason === "probeFailed") - ) { - return yield* new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: - "The reset was applied, but Claude could not confirm the new limits. Refresh to check.", - }); - } + // Same rules as Codex: serialised on the config directory that holds the + // login, one request id kept until Claude answers (a cooldown or rate + // limit is an answer), then a re-probe. + const consumeResetCredit: NonNullable = Effect.fn( + "ClaudeDriver.consumeResetCredit", + )( + function* () { + const current = yield* snapshot.getSnapshot; + const grantId = current.usageLimits?.resetCredits?.nextCreditId; + if (!grantId || !current.version) return "noCredit" as const; + const version = current.version; + return yield* resetCreditCoordinator.redeem( + configDir, + (requestId) => + ClaudeResetCredits.consumeClaudeResetCredit({ + configDir, + accountConfigPath, + version, + grantId, + requestId, }), - ), + ClaudeResetCredits.isSettledClaudeResetCreditFailure, ); + }, + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: + cause._tag === "ClaudeResetCreditError" + ? cause.message + : "Claude could not redeem the reset.", + cause, + }), + ), + Effect.tap((outcome) => + Effect.gen(function* () { + const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; + yield* Cache.invalidateAll(capabilitiesProbeCache); + const refreshed = yield* snapshot.refresh; + const after = refreshed.usageLimits?.checkedAt; + if ( + outcome === "reset" && + (after === undefined || + after === before || + refreshed.usageLimits?.unavailable?.reason === "probeFailed") + ) { + return yield* new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: + "The reset was applied, but Claude could not confirm the new limits. Refresh to check.", + }); + } + }), + ), + ); - return { - instanceId, - driverKind: DRIVER_KIND, - continuationIdentity: { - ...fallbackContinuationIdentity, - continuationKey: continuationGroupKey, - }, - displayName, - accentColor, - enabled, - snapshot, - invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache), - snapshotForCwd: (cwd: string) => - snapshot.getSnapshot.pipe( - Effect.flatMap((machineSnapshot) => - probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv), - ), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity: { + ...fallbackContinuationIdentity, + continuationKey: continuationGroupKey, + }, + displayName, + accentColor, + enabled, + snapshot, + invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache), + snapshotForCwd: (cwd: string) => + snapshot.getSnapshot.pipe( + Effect.flatMap((machineSnapshot) => + probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv), ), - orchestrationAdapter, - textGeneration, - consumeResetCredit, - } satisfies ProviderInstance; - }), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + orchestrationAdapter, + textGeneration, + consumeResetCredit, + } satisfies ProviderInstance; + }), }; diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts index b03bd994a14b..e1319caf69b1 100644 --- a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts @@ -24,30 +24,6 @@ function withWindowsResolution(input: { } describe("resolveClaudeSdkExecutablePath", () => { - it.effect("returns the configured path unchanged on non-Windows platforms", () => - Effect.gen(function* () { - expect( - yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( - Effect.provideService(HostProcess.Platform, "darwin"), - Effect.provideService(SpawnExecutableResolution, () => { - throw new Error("must not resolve on non-Windows platforms"); - }), - ), - ).toBe("claude"); - }), - ); - - it.effect("returns the resolved absolute path for native Windows executables", () => - Effect.gen(function* () { - const nativeBinary = "C:\\Users\\dev\\.local\\bin\\claude.exe"; - expect( - yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( - withWindowsResolution({ resolvedCommand: nativeBinary }), - ), - ).toBe(nativeBinary); - }), - ); - it.effect("follows an npm launcher shim to the packaged native binary", () => Effect.gen(function* () { expect( @@ -111,14 +87,4 @@ describe("resolveClaudeSdkExecutablePath", () => { ).toBe("claude"); }), ); - - it.effect("returns the configured path when command resolution finds nothing", () => - Effect.gen(function* () { - expect( - yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( - withWindowsResolution({ resolvedCommand: undefined }), - ), - ).toBe("claude"); - }), - ); }); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index fb1caf753f86..b9eb3b806ce0 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -6,7 +6,6 @@ import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; import { - claudeSignedOutMessage, makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey, makeClaudeEnvironment, @@ -15,23 +14,6 @@ import { it.layer(NodeServices.layer)("ClaudeHome", (it) => { describe("Claude home resolution", () => { - it.effect("treats empty, ~/.claude, and the expanded default as the same Claude home", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const resolved = path.resolve(path.join(NodeOS.homedir(), ".claude")); - - expect(yield* resolveClaudeHomePath({ homePath: "" })).toBe(resolved); - expect(yield* resolveClaudeHomePath({ homePath: "~/.claude" })).toBe(resolved); - expect(yield* resolveClaudeHomePath({ homePath: resolved })).toBe(resolved); - expect(yield* makeClaudeEnvironment({ homePath: "" })).toBe(process.env); - - const key = `claude:home:${resolved}`; - expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe(key); - expect(yield* makeClaudeContinuationGroupKey({ homePath: "~/.claude" })).toBe(key); - expect(yield* makeClaudeContinuationGroupKey({ homePath: resolved })).toBe(key); - }), - ); - it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -65,17 +47,6 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { }), ); - it("points the signed-out hint at the configured Claude home", () => { - expect(claudeSignedOutMessage({ configDir: undefined, cwd: "/synthetic" })).toContain( - "run `claude auth login`", - ); - const configDir = "/synthetic/Claude work's $literal"; - const message = claudeSignedOutMessage({ configDir, cwd: "/synthetic/project" }); - expect(message).toContain(`CLAUDE_CONFIG_DIR set to "${configDir}"`); - expect(message).not.toContain("CLAUDE_CONFIG_DIR="); - expect(message).toContain("then start a new thread"); - }); - it.effect("separates capability probes by cwd", () => Effect.gen(function* () { const config = { binaryPath: "claude", homePath: "" }; diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts index abb80482a264..975eb841b67a 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts @@ -1,18 +1,24 @@ import { describe, expect, it } from "vite-plus/test"; -import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; +import { planClaudeSkillDispatch, type ClaudeSkillTarget } from "./ClaudeSkillDispatch.ts"; -const SKILLS = new Set(["2spec", "implement", "review", "re-release-version"]); +const skillPath = (name: string) => `/home/me/.claude/skills/${name}/SKILL.md`; + +const skillMap = (names: Iterable) => + new Map([...names].map((name) => [name, { path: skillPath(name) }])); + +const SKILLS = skillMap(["2spec", "implement", "review", "re-release-version"]); describe("planClaudeSkillDispatch", () => { + // regression: #9128 skills picked from the composer did not run it("leaves a prompt without a known skill untouched", () => { expect(planClaudeSkillDispatch("fix the build", SKILLS)).toBeUndefined(); expect(planClaudeSkillDispatch("echo $HOME then $unknown", SKILLS)).toBeUndefined(); }); - it("moves a mid-prompt mention into a trailing slash command", () => { + it("keeps the whole request ahead of a mid-prompt slash command", () => { expect(planClaudeSkillDispatch("ok, now $implement all the tickets", SKILLS)).toEqual({ - leadingText: "ok, now", + leadingText: "ok, now /implement all the tickets", commandText: "/implement all the tickets", skillName: "implement", }); @@ -26,17 +32,9 @@ describe("planClaudeSkillDispatch", () => { }); }); - it("dispatches a known skill whose name begins with a digit", () => { - expect(planClaudeSkillDispatch("use $2spec for this", SKILLS)).toEqual({ - leadingText: "use", - commandText: "/2spec for this", - skillName: "2spec", - }); - }); - it("dispatches the last mention and rewrites earlier ones inline", () => { expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({ - leadingText: "/review the diff, then", + leadingText: "/review the diff, then /implement the fixes", commandText: "/implement the fixes", skillName: "implement", }); @@ -50,7 +48,7 @@ describe("planClaudeSkillDispatch", () => { SKILLS, ), ).toEqual({ - leadingText: "/review the diff, then", + leadingText: "/review the diff, then /implement the fixes", commandText: "/implement the fixes", skillName: "implement", }); @@ -63,12 +61,8 @@ describe("planClaudeSkillDispatch", () => { } }); - it("ignores a dollar token glued to other text", () => { - expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined(); - }); - it("ignores currency amounts and compact monetary expressions", () => { - const skillsWithCurrency = new Set([...SKILLS, "20", "20k", "100M", "1e6"]); + const skillsWithCurrency = skillMap([...SKILLS.keys(), "20", "20k", "100M", "1e6"]); for (const symbol of ["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"]) { expect( planClaudeSkillDispatch( @@ -78,4 +72,21 @@ describe("planClaudeSkillDispatch", () => { ).toBeUndefined(); } }); + + it("bounds notes for repeated user-only skills and leaves the native dispatch unannotated", () => { + const skills = new Map([ + ["interrogate", { path: skillPath("interrogate"), userInvocationOnly: true }], + ["implement", { path: skillPath("implement"), userInvocationOnly: true }], + ]); + expect( + planClaudeSkillDispatch( + "$interrogate then $interrogate then $implement and $implement the fixes", + skills, + ), + ).toEqual({ + leadingText: `/interrogate [The user invoked this skill. Follow the instructions at: ${skillPath("interrogate")}] then /interrogate then /implement and /implement the fixes`, + commandText: "/implement the fixes", + skillName: "implement", + }); + }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts index dac74b36c20b..2524dcbb6c7d 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -1,9 +1,11 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; + /** * Claude Code's only user-side invocation is a text block whose first character * is `/`: the harness expands `/name args` into the SKILL.md body, and every * character after the name (newlines included) arrives as `ARGUMENTS`. Only one * skill expands per message; a second `/x` becomes argument text - * (anthropics/claude-code#87113). + * ([#87113](https://github.com/anthropics/claude-code/issues/87113)). */ /** @@ -14,7 +16,10 @@ const SKILL_MENTION_PATTERN = /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; +export type ClaudeSkillTarget = Pick; + export interface ClaudeSkillDispatch { + /** The whole prompt with mentions as `/name`, or `undefined` when the mention opens it. */ readonly leadingText: string | undefined; /** `/name` plus the trailing text, ready to be the message's last text block. */ readonly commandText: string; @@ -23,33 +28,39 @@ export interface ClaudeSkillDispatch { export function planClaudeSkillDispatch( prompt: string, - skillNames: ReadonlySet, + skills: ReadonlyMap, ): ClaudeSkillDispatch | undefined { const mentions = [...prompt.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => { const name = match[2] ?? ""; - if (!skillNames.has(name)) return []; + const skill = skills.get(name); + if (!skill) return []; const start = (match.index ?? 0) + (match[1]?.length ?? 0); - return [{ name, start, end: (match.index ?? 0) + match[0].length }]; + return [{ name, skill, start, end: (match.index ?? 0) + match[0].length }]; }); const last = mentions.at(-1); if (!last) { return undefined; } - const leading = prompt.slice(0, last.start); - const trailing = prompt.slice(last.end); - const leadingWithInlineSlashes = mentions - .slice(0, -1) - .reduceRight( - (text, mention) => - `${text.slice(0, mention.start)}/${mention.name}${text.slice(mention.end)}`, - leading, - ) - .trimEnd(); + // Only the final skill gets native expansion. Earlier user-only skills need + // a fallback to their raw file because Claude's Skill tool refuses them. + const annotated = new Set([last.name]); + let promptWithInlineSlashes = ""; + let cursor = 0; + for (const mention of mentions) { + promptWithInlineSlashes += `${prompt.slice(cursor, mention.start)}/${mention.name}`; + if (mention.skill.userInvocationOnly === true && !annotated.has(mention.name)) { + promptWithInlineSlashes += ` [The user invoked this skill. Follow the instructions at: ${mention.skill.path}]`; + annotated.add(mention.name); + } + cursor = mention.end; + } + promptWithInlineSlashes = `${promptWithInlineSlashes}${prompt.slice(cursor)}`.trimEnd(); return { - leadingText: leadingWithInlineSlashes.length > 0 ? leadingWithInlineSlashes : undefined, - commandText: `/${last.name}${trailing}`.trimEnd(), + leadingText: + prompt.slice(0, last.start).trim().length > 0 ? promptWithInlineSlashes : undefined, + commandText: `/${last.name}${prompt.slice(last.end)}`.trimEnd(), skillName: last.name, }; } diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 6529fdd41280..62c322cabf12 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -1,11 +1,10 @@ -import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { discoverClaudeSkills, skillOverrideSettingsPaths } from "./ClaudeSkills.ts"; +import { discoverClaudeSkills } from "./ClaudeSkills.ts"; import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; const writeSkill = Effect.fn(function* ( @@ -21,6 +20,276 @@ const writeSkill = Effect.fn(function* ( }); it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { + it.effect("discovers enabled plugin skills with manifest names and ignores skill overrides", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const plugin = path.join(configDir, "plugins", "cache", "shop", "plugin", "1.0.0"); + const disabled = path.join(configDir, "plugins", "disabled"); + yield* writeSkill(path.join(disabled, "skills"), "hidden", "# Hidden"); + yield* writeSkill(path.join(plugin, "skills"), "plain", "# Plain"); + yield* writeSkill( + path.join(plugin, "engineering"), + "review", + "---\nname: review-alias\ndescription: Review changes.\ndisable-model-invocation: true\nuser-invocable: false\n---\n", + ); + yield* fs.makeDirectory(path.join(plugin, ".claude-plugin"), { recursive: true }); + yield* fs.writeFileString( + path.join(plugin, ".claude-plugin", "plugin.json"), + '{"name":"manifest-plugin","skills":["./engineering/review"]}', + ); + yield* fs.writeFileString( + path.join(configDir, "plugins", "installed_plugins.json"), + JSON.stringify({ + plugins: { + "plugin@shop": [{ scope: "user", installPath: plugin }], + "disabled@shop": [{ scope: "user", installPath: disabled }], + "unconfigured@shop": [{ scope: "user", installPath: disabled }], + }, + }), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{"enabledPlugins":{"plugin@shop":true,"disabled@shop":false,"uninstalled@shop":true,"other@shop":["^1.0.0"]},"skillOverrides":{"manifest-plugin:plain":"off"}}', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + assert.deepEqual(skills, [ + { + name: "manifest-plugin:plain", + path: path.join(plugin, "skills", "plain", "SKILL.md"), + enabled: true, + scope: "user", + }, + { + name: "manifest-plugin:review-alias", + path: path.join(plugin, "engineering", "review", "SKILL.md"), + enabled: true, + scope: "user", + description: "Review changes.", + userInvocationOnly: true, + userInvocable: false, + }, + ]); + }), + ); + + it.effect("merges plugin enablement and selects installs in native registry order", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + const nestedWorkspace = path.join(workspace, "packages", "app"); + const aliasedWorkspace = path.join(tempDir, "aliased-workspace"); + const userInstall = path.join(configDir, "plugins", "user"); + const projectInstall = path.join(configDir, "plugins", "project"); + const localInstall = path.join(configDir, "plugins", "local"); + const managedInstall = path.join(configDir, "plugins", "managed"); + yield* fs.makeDirectory(path.join(workspace, ".git"), { recursive: true }); + yield* fs.makeDirectory(nestedWorkspace, { recursive: true }); + yield* fs.symlink(workspace, aliasedWorkspace); + for (const install of [userInstall, projectInstall, localInstall, managedInstall]) { + yield* writeSkill(path.join(install, "skills"), "review", "# Review"); + } + yield* fs.writeFileString( + path.join(configDir, "plugins", "installed_plugins.json"), + JSON.stringify({ + plugins: { + "plugin@shop": [ + { scope: "project", projectPath: aliasedWorkspace, installPath: localInstall }, + { scope: "project", projectPath: workspace, installPath: projectInstall }, + { scope: "user", installPath: userInstall }, + { scope: "managed", installPath: managedInstall }, + ], + "local@shop": [ + { scope: "local", projectPath: workspace, installPath: localInstall }, + { scope: "project", projectPath: workspace, installPath: projectInstall }, + { scope: "managed", installPath: path.join(configDir, "plugins", "missing") }, + { scope: "managed", installPath: managedInstall }, + { scope: "user", installPath: userInstall }, + ], + }, + }), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{"enabledPlugins":{"plugin@shop":true,"local@shop":false}}', + ); + yield* fs.makeDirectory(path.join(workspace, ".claude"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspace, ".claude", "settings.json"), + '{"enabledPlugins":{"plugin@shop":false,"local@shop":true,"other@shop":["^1.0.0"]}}', + ); + const projectSettings = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + assert.deepEqual( + projectSettings.map((skill) => [skill.name, skill.scope, skill.path]), + [["local:review", "project", path.join(localInstall, "skills", "review", "SKILL.md")]], + ); + assert.deepEqual( + (yield* discoverClaudeSkills({ homePath: configDir }, nestedWorkspace)).map((skill) => [ + skill.name, + skill.scope, + skill.path, + ]), + [["plugin:review", "project", path.join(projectInstall, "skills", "review", "SKILL.md")]], + ); + yield* fs.writeFileString( + path.join(workspace, ".claude", "settings.local.json"), + '{"enabledPlugins":{"plugin@shop":true,"local@shop":false},"skillOverrides":{"invalid":"bogus"}}', + ); + assert.deepEqual( + yield* discoverClaudeSkills({ homePath: configDir }, workspace), + projectSettings, + ); + yield* fs.writeFileString( + path.join(workspace, ".claude", "settings.local.json"), + '{"enabledPlugins":{"plugin@shop":true,"local@shop":true}}', + ); + const localSettings = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + assert.deepEqual( + localSettings.map((skill) => [skill.name, skill.scope, skill.path]), + [ + ["local:review", "project", path.join(localInstall, "skills", "review", "SKILL.md")], + ["plugin:review", "project", path.join(projectInstall, "skills", "review", "SKILL.md")], + ], + ); + assert.deepEqual( + yield* discoverClaudeSkills({ homePath: configDir }, nestedWorkspace), + localSettings, + ); + assert.deepEqual( + yield* discoverClaudeSkills({ homePath: configDir }, aliasedWorkspace), + localSettings, + ); + yield* fs.writeFileString( + path.join(workspace, ".claude", "settings.local.json"), + '{"enabledPlugins":{"local@shop":["^1.0.0"]}}', + ); + assert.deepEqual(yield* discoverClaudeSkills({ homePath: configDir }, workspace), []); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{"enabledPlugins":{"plugin@shop":true,"local@shop":true}}', + ); + const separateRepository = path.join(workspace, "separate"); + yield* fs.makeDirectory(path.join(separateRepository, ".git"), { recursive: true }); + for (const cwd of [undefined, path.join(tempDir, "elsewhere"), separateRepository]) { + const elsewhere = yield* discoverClaudeSkills({ homePath: configDir }, cwd); + assert.deepEqual( + elsewhere.map((skill) => [skill.name, skill.scope, skill.path]), + [ + ["local:review", "user", path.join(managedInstall, "skills", "review", "SKILL.md")], + ["plugin:review", "user", path.join(userInstall, "skills", "review", "SKILL.md")], + ], + ); + } + const worktree = path.join(tempDir, "worktree"); + const worktreeGitDirectory = path.join(workspace, ".git", "worktrees", "fixture"); + yield* fs.makeDirectory(worktree); + yield* fs.makeDirectory(worktreeGitDirectory, { recursive: true }); + yield* fs.writeFileString(path.join(worktree, ".git"), `gitdir: ${worktreeGitDirectory}\n`); + yield* fs.writeFileString(path.join(worktreeGitDirectory, "commondir"), "../..\n"); + yield* fs.writeFileString( + path.join(worktreeGitDirectory, "gitdir"), + path.join(worktree, ".git"), + ); + assert.deepEqual( + yield* discoverClaudeSkills({ homePath: configDir }, worktree), + localSettings, + ); + const linkedWorktree = path.join(tempDir, "linked-worktree"); + yield* fs.symlink(worktree, linkedWorktree); + assert.deepEqual( + yield* discoverClaudeSkills({ homePath: configDir }, linkedWorktree), + localSettings, + ); + }), + ); + + it.effect("publishes and dispatches canonical plugin folder names regardless of aliases", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const plugin = path.join(configDir, "plugins", "installed"); + yield* writeSkill(path.join(plugin, "skills"), "review", "---\nname: review-alias\n---\n"); + yield* writeSkill(path.join(plugin, "skills"), "alias", "---\nname: dot.alias\n---\n"); + yield* fs.writeFileString( + path.join(configDir, "plugins", "installed_plugins.json"), + JSON.stringify({ plugins: { "plugin@shop": [{ scope: "user", installPath: plugin }] } }), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{"enabledPlugins":{"plugin@shop":true}}', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + assert.deepEqual( + planClaudeSkillDispatch( + "$plugin:alias focus on auth", + new Map(skills.map((skill) => [skill.name, skill])), + ), + { + leadingText: undefined, + commandText: "/plugin:alias focus on auth", + skillName: "plugin:alias", + }, + ); + assert.deepEqual( + skills.map((skill) => skill.name), + ["plugin:alias", "plugin:review"], + ); + }), + ); + + it.effect("uses the native cache root, root-skill fallback and manifest path boundaries", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspace = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(workspace, "config"); + const pluginsDir = path.join(workspace, "plugin-cache"); + const plugin = path.join(pluginsDir, "installed"); + const outside = path.join(workspace, "outside"); + yield* writeSkill(pluginsDir, "installed", "---\nname: plugin:root.alias\n---\n"); + yield* fs.makeDirectory(configDir); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{"enabledPlugins":{"plugin@shop":true}}', + ); + yield* fs.writeFileString( + path.join(pluginsDir, "installed_plugins.json"), + JSON.stringify({ plugins: { "plugin@shop": [{ scope: "user", installPath: plugin }] } }), + ); + const environment = { CLAUDE_CODE_PLUGIN_CACHE_DIR: "plugin-cache" }; + assert.deepEqual( + (yield* discoverClaudeSkills({ homePath: configDir }, workspace, environment)).map( + (skill) => skill.name, + ), + ["plugin:root-alias"], + ); + + yield* writeSkill(path.join(plugin, "skills"), "plain", "# Plain"); + yield* writeSkill(plugin, "custom", "---\nname: custom.review\n---\n"); + yield* writeSkill(outside, "escaped", "# Outside"); + yield* fs.symlink(outside, path.join(plugin, "escape")); + yield* fs.makeDirectory(path.join(plugin, ".claude-plugin")); + yield* fs.writeFileString( + path.join(plugin, ".claude-plugin", "plugin.json"), + '{"skills":["./custom","./escape","../../outside"]}', + ); + assert.deepEqual( + (yield* discoverClaudeSkills({ homePath: configDir }, workspace, environment)).map( + (skill) => skill.name, + ), + ["plugin:custom-review", "plugin:plain"], + ); + }), + ); + it.effect("discovers user and project skills with frontmatter metadata", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -106,7 +375,10 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const settings = { homePath: configDir, launchArgs: `--add-dir "${extraDir}"` }; const skills = yield* discoverClaudeSkills(settings, workspace); assert.deepEqual( - planClaudeSkillDispatch("$demo", new Set(skills.map((skill) => skill.name))), + planClaudeSkillDispatch( + "$demo", + new Map(skills.map((skill) => [skill.name, { path: skill.path }])), + ), { leadingText: undefined, commandText: "/demo", @@ -127,28 +399,6 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); - it.effect("ignores .agents/skills, which Claude Code does not load", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); - const configDir = path.join(tempDir, "claude-home"); - const workspace = path.join(tempDir, "workspace"); - - // Verified against the CLI: `/review` here is answered with - // `Unknown command`, so offering it would dispatch a dead command. - yield* writeSkill( - path.join(workspace, ".agents", "skills"), - "review", - ["---", "name: review", "description: Review the changes.", "---"].join("\n"), - ); - - const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); - - assert.deepEqual(skills, []); - }), - ); - it.effect("prefers user skills on name collisions even with a stray .agents copy", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -187,33 +437,6 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); - it.effect("prefers user skills over project skills on name collisions", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); - const configDir = path.join(tempDir, "claude-home"); - const workspace = path.join(tempDir, "workspace"); - - yield* writeSkill( - path.join(configDir, "skills"), - "deploy", - ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), - ); - yield* writeSkill( - path.join(workspace, ".claude", "skills"), - "deploy", - ["---", "name: deploy", "description: Project deploy.", "---"].join("\n"), - ); - - const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); - - assert.equal(skills.length, 1); - assert.equal(skills[0]?.scope, "user"); - assert.equal(skills[0]?.description, "User deploy."); - }), - ); - it.effect("recovers colon-bearing descriptions with invocation metadata intact", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -358,48 +581,6 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); - it.effect("marks skills that only the user can invoke", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); - const configDir = path.join(tempDir, "claude-home"); - const workspace = path.join(tempDir, "workspace"); - - yield* writeSkill( - path.join(workspace, ".claude", "skills"), - "re-release-version", - [ - "---", - "name: re-release-version", - "description: Move the current tag forward.", - "disable-model-invocation: true", - "---", - "", - "# Body", - ].join("\n"), - ); - yield* writeSkill( - path.join(workspace, ".claude", "skills"), - "release-version", - ["---", "name: release-version", "description: Cut a release.", "---", "", "# Body"].join( - "\n", - ), - ); - - const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); - - assert.equal( - skills.find((skill) => skill.name === "re-release-version")?.userInvocationOnly, - true, - ); - assert.equal( - skills.find((skill) => skill.name === "release-version")?.userInvocationOnly, - undefined, - ); - }), - ); - it.effect("disables skills switched off by skillOverrides", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -600,86 +781,6 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); - it.effect("lets the administrator's managed policy outrank every other settings file", () => - Effect.gen(function* () { - const path = yield* Path.Path.pipe(Effect.provide(NodePath.layerPosix)); - const win32Path = yield* Path.Path.pipe(Effect.provide(NodePath.layerWin32)); - - for (const [platform, expected] of [ - ["darwin", "/Library/Application Support/ClaudeCode/managed-settings.json"], - ["linux", "/etc/claude-code/managed-settings.json"], - ] as const) { - const paths = skillOverrideSettingsPaths(path, "/home/.claude", "/workspace", platform, {}); - assert.deepEqual(paths, [ - "/home/.claude/settings.json", - "/workspace/.claude/settings.json", - "/workspace/.claude/settings.local.json", - expected, - ]); - } - - assert.deepEqual( - skillOverrideSettingsPaths(win32Path, "C:\\Users\\me\\.claude", undefined, "win32", { - PROGRAMDATA: "C:\\ProgramData", - }).at(-1), - "C:\\ProgramData\\ClaudeCode\\managed-settings.json", - ); - assert.deepEqual( - skillOverrideSettingsPaths(win32Path, "C:\\Users\\me\\.claude", undefined, "win32", {}), - ["C:\\Users\\me\\.claude\\settings.json"], - ); - - assert.deepEqual( - skillOverrideSettingsPaths( - path, - "/home/.claude", - "/repo/packages/app", - "linux", - {}, - "/repo", - ), - [ - "/home/.claude/settings.json", - "/repo/packages/app/.claude/settings.json", - "/repo/packages/app/.claude/settings.local.json", - "/repo/.claude/settings.local.json", - "/etc/claude-code/managed-settings.json", - ], - ); - assert.deepEqual( - skillOverrideSettingsPaths(path, "/home/.claude", "/repo", "linux", {}, "/repo"), - [ - "/home/.claude/settings.json", - "/repo/.claude/settings.json", - "/repo/.claude/settings.local.json", - "/etc/claude-code/managed-settings.json", - ], - ); - }), - ); - - it.effect("records a skill Claude Code keeps out of its own slash commands", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); - const configDir = path.join(tempDir, "claude-home"); - - yield* writeSkill( - path.join(configDir, "skills"), - "agent-only", - ["---", "name: agent-only", "user-invocable: false", "---", "", "# Body"].join("\n"), - ); - - const skills = yield* discoverClaudeSkills({ homePath: configDir }); - - assert.deepEqual( - skills.map((skill) => [skill.name, skill.userInvocable]), - [["agent-only", false]], - ); - }), - ); - it.effect("identifies a skill by its directory, as Claude Code does", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -706,6 +807,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + // regression: #9128 skills picked from the composer did not run it.effect("switches a skill off by its directory name", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -772,19 +874,4 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { ); }), ); - - it.effect("returns an empty list when no skill roots exist", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); - - const skills = yield* discoverClaudeSkills( - { homePath: path.join(tempDir, "missing-home") }, - path.join(tempDir, "missing-workspace"), - ); - - assert.deepEqual(skills, []); - }), - ); }); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 9828dafe4528..ff2628761181 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -2,16 +2,24 @@ import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { parse as parseYamlDocument } from "yaml"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; type ClaudeSkillScope = "user" | "project"; +type ClaudeSkillRoot = { + readonly directory: string; + readonly scope: ClaudeSkillScope; + readonly prefix?: string; +}; + const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; type SkillFrontmatter = @@ -19,6 +27,7 @@ type SkillFrontmatter = | { readonly kind: "malformed" } | { readonly kind: "parsed"; + readonly name?: string; readonly description?: string; readonly userInvocationOnly?: boolean; readonly userInvocable?: boolean; @@ -85,9 +94,11 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { } const record = parsed as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; const description = typeof record.description === "string" ? record.description.trim() : ""; return { kind: "parsed", + ...(name ? { name } : {}), ...(description ? { description } : {}), ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true ? { userInvocationOnly: true } @@ -113,7 +124,7 @@ function claudeManagedSettingsPath( return "/etc/claude-code/managed-settings.json"; } -export function skillOverrideSettingsPaths( +function skillOverrideSettingsPaths( path: Path.Path, configDirPath: string, cwd: string | undefined, @@ -157,6 +168,43 @@ const findRepositoryRoot = Effect.fn("findRepositoryRoot")(function* ( } }); +const findPluginRepositoryRoot = Effect.fn("findPluginRepositoryRoot")(function* (cwd: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* findRepositoryRoot(cwd); + if (root === undefined) return undefined; + const gitFile = yield* fileSystem + .readFileString(path.join(root, ".git")) + .pipe(Effect.orElseSucceed(() => "")); + if (!gitFile.trim().startsWith("gitdir:")) return root; + const gitDirectory = path.resolve(root, gitFile.trim().slice(7).trim()); + const commonFile = yield* fileSystem + .readFileString(path.join(gitDirectory, "commondir")) + .pipe(Effect.orElseSucceed(() => "")); + const worktreeFile = yield* fileSystem + .readFileString(path.join(gitDirectory, "gitdir")) + .pipe(Effect.orElseSucceed(() => "")); + if (!commonFile.trim() || !worktreeFile.trim()) return root; + const commonDirectory = path.resolve(gitDirectory, commonFile.trim()); + const backlinkPath = yield* fileSystem + .realPath(path.resolve(gitDirectory, worktreeFile.trim())) + .pipe(Effect.orElseSucceed(() => undefined)); + const worktreeGitPath = yield* fileSystem + .realPath(path.join(root, ".git")) + .pipe(Effect.orElseSucceed(() => undefined)); + if ( + path.dirname(gitDirectory) !== path.join(commonDirectory, "worktrees") || + backlinkPath === undefined || + backlinkPath !== worktreeGitPath + ) { + return root; + } + // Claude shares plugin install records across linked worktrees, using their common repository. + return path.basename(commonDirectory) === ".git" + ? path.dirname(commonDirectory) + : commonDirectory; +}); + /** * The CLI validates the whole map, not each entry: verified against it, one * entry with an unknown value (or a boolean) makes it drop every override in @@ -167,12 +215,13 @@ const SkillOverrideValue = Schema.Literals(["on", "name-only", "user-invocable-o // Lenient because these settings files are hand-edited and Claude Code itself // tolerates comments and trailing commas in them. -const SkillOverrideSettings = fromLenientJson( +const SkillSettings = fromLenientJson( Schema.Struct({ skillOverrides: Schema.optional(Schema.Record(Schema.String, SkillOverrideValue)), + enabledPlugins: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }), ); -const decodeSkillOverrideSettings = Schema.decodeUnknownEffect(SkillOverrideSettings); +const decodeSkillSettings = Schema.decodeUnknownEffect(SkillSettings); type SkillOverride = { readonly enabled: boolean; @@ -191,15 +240,16 @@ function parseSkillOverride(value: typeof SkillOverrideValue.Type): SkillOverrid } } -const readSkillOverrides = Effect.fn("readSkillOverrides")(function* ( +const readSkillSettings = Effect.fn("readSkillSettings")(function* ( configDirPath: string, cwd: string | undefined, environment: NodeJS.ProcessEnv, -): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { +) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcess.Platform; const overridesByName = new Map(); + const enabledPlugins = new Map(); const repositoryRoot = cwd === undefined ? undefined : yield* findRepositoryRoot(cwd); for (const settingsPath of skillOverrideSettingsPaths( @@ -217,26 +267,150 @@ const readSkillOverrides = Effect.fn("readSkillOverrides")(function* ( continue; } - const parsed = yield* decodeSkillOverrideSettings(contents).pipe( + const parsed = yield* decodeSkillSettings(contents).pipe( Effect.tapError((cause) => - Effect.logDebug("claude settings file is unreadable; ignoring skillOverrides", { + Effect.logDebug("claude settings file is unreadable; ignoring skill settings", { path: settingsPath, cause, }), ), Effect.orElseSucceed(() => undefined), ); - const overrides = parsed?.skillOverrides; - if (!overrides) { - continue; - } - - for (const [name, value] of Object.entries(overrides)) { + for (const [name, value] of Object.entries(parsed?.skillOverrides ?? {})) { overridesByName.set(name, parseSkillOverride(value)); } + for (const [key, enabled] of Object.entries(parsed?.enabledPlugins ?? {})) { + if (typeof enabled === "boolean") { + enabledPlugins.set(key, enabled); + } else { + // Extended formats replace lower-precedence values without enabling the plugin here. + enabledPlugins.delete(key); + } + } } - return overridesByName; + return { skillOverrides: overridesByName, enabledPlugins }; +}); + +const InstalledPlugins = Schema.fromJsonString( + Schema.Struct({ plugins: Schema.Record(Schema.String, Schema.Array(Schema.Unknown)) }), +); +const decodeInstalledPlugin = Schema.decodeUnknownOption( + Schema.Struct({ + scope: Schema.Literals(["user", "project", "local", "managed"]), + installPath: Schema.NonEmptyString, + projectPath: Schema.optional(Schema.NonEmptyString), + }), +); +const PluginManifest = Schema.fromJsonString( + Schema.Struct({ + name: Schema.optional(Schema.NonEmptyString), + skills: Schema.optional(Schema.Union([Schema.String, Schema.Array(Schema.String)])), + }), +); + +function isInsideDirectory(path: Path.Path, directory: string, candidate: string): boolean { + const relative = path.relative(directory, candidate); + return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); +} + +const discoverPluginSkillRoots = Effect.fn("discoverPluginSkillRoots")(function* ( + configDirPath: string, + cwd: string | undefined, + enabledPlugins: ReadonlyMap, + environment: NodeJS.ProcessEnv, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* HostProcess.HomeDirectory; + const pluginDirectory = environment.CLAUDE_CODE_PLUGIN_CACHE_DIR; + const pluginsPath = pluginDirectory + ? path.resolve(cwd ?? ".", expandHomePath(pluginDirectory, home)) + : path.join(configDirPath, "plugins"); + const installed = yield* fileSystem + .readFileString(path.join(pluginsPath, "installed_plugins.json")) + .pipe( + Effect.flatMap(Schema.decodeUnknownEffect(InstalledPlugins)), + Effect.orElseSucceed(() => undefined), + ); + if (installed === undefined) return []; + const roots: Array = []; + // The CLI's process.cwd() resolves symlinks; registry projectPath values remain lexical. + const workspace = + cwd === undefined + ? undefined + : yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => path.resolve(cwd))); + const repositoryRoot = + workspace === undefined ? undefined : yield* findPluginRepositoryRoot(workspace); + + for (const [key, entries] of Object.entries(installed.plugins).sort()) { + if (enabledPlugins.get(key) !== true) continue; + const applicable = yield* Effect.filter( + entries.flatMap((entry) => Option.toArray(decodeInstalledPlugin(entry))), + (record) => + Effect.gen(function* () { + if (record.scope === "user" || record.scope === "managed") return true; + if (record.projectPath === undefined || workspace === undefined) return false; + if (path.resolve(record.projectPath) === workspace) return true; + return ( + repositoryRoot !== undefined && + (yield* findPluginRepositoryRoot(record.projectPath)) === repositoryRoot + ); + }), + ); + // The native loader keeps registry order, preferring the first install that exists. + const existing = yield* Effect.findFirst(applicable, (record) => + fileSystem + .exists(path.resolve(cwd ?? ".", record.installPath)) + .pipe(Effect.orElseSucceed(() => false)), + ); + const install = Option.getOrElse(existing, () => applicable[0]); + if (!install) continue; + const scope = install.scope === "user" || install.scope === "managed" ? "user" : "project"; + const installPath = path.resolve(cwd ?? ".", install.installPath); + const manifest = yield* fileSystem + .readFileString(path.join(installPath, ".claude-plugin", "plugin.json")) + .pipe( + Effect.flatMap(Schema.decodeUnknownEffect(PluginManifest)), + Effect.orElseSucceed(() => undefined), + ); + const prefix = manifest?.name ?? key.split("@")[0]; + if (!prefix) continue; + + const defaultDirectory = path.join(installPath, "skills"); + const hasDefaultDirectory = yield* fileSystem + .exists(defaultDirectory) + .pipe(Effect.orElseSucceed(() => false)); + if (hasDefaultDirectory) roots.push({ directory: defaultDirectory, scope, prefix }); + const declared = manifest?.skills; + if (declared === undefined && !hasDefaultDirectory) { + const hasRootSkill = yield* fileSystem + .exists(path.join(installPath, "SKILL.md")) + .pipe(Effect.orElseSucceed(() => false)); + if (hasRootSkill) roots.push({ directory: installPath, scope, prefix }); + } + + const canonicalInstallPath = yield* fileSystem + .realPath(installPath) + .pipe(Effect.orElseSucceed(() => undefined)); + for (const relative of typeof declared === "string" ? [declared] : (declared ?? [])) { + const directory = path.resolve(installPath, relative); + if (!isInsideDirectory(path, installPath, directory) || canonicalInstallPath === undefined) { + continue; + } + const canonicalDirectory = yield* fileSystem + .realPath(directory) + .pipe(Effect.orElseSucceed(() => undefined)); + // The native loader rejects manifest roots that escape through a symlink, too. + if ( + canonicalDirectory !== undefined && + isInsideDirectory(path, canonicalInstallPath, canonicalDirectory) + ) { + roots.push({ directory, scope, prefix }); + } + } + } + return roots; }); const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* ( @@ -275,11 +449,15 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd); - const skillOverrides = yield* readSkillOverrides(configDirPath, cwd, environment ?? process.env); + const { skillOverrides, enabledPlugins } = yield* readSkillSettings( + configDirPath, + cwd, + environment ?? process.env, + ); // Use the session's argument parser so discovery sees the same --add-dir value. const additionalDirectory = parseCliArgs(config.launchArgs ?? "").flags["add-dir"]; - const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ + const roots: ReadonlyArray = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), ...(additionalDirectory && (cwd || path.isAbsolute(additionalDirectory)) @@ -294,16 +472,31 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* }, ] : []), + ...(yield* discoverPluginSkillRoots( + configDirPath, + cwd, + enabledPlugins, + environment ?? process.env, + )), ]; const skillsByName = new Map(); for (const root of roots) { - const entries = yield* fileSystem - .readDirectory(root.directory) - .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + const singleSkill = + root.prefix !== undefined && + (yield* fileSystem + .exists(path.join(root.directory, "SKILL.md")) + .pipe(Effect.orElseSucceed(() => false))); + const entries = singleSkill + ? [path.basename(root.directory)] + : yield* fileSystem + .readDirectory(root.directory) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); for (const entry of [...entries].sort()) { - const skillPath = path.join(root.directory, entry, "SKILL.md"); + const skillPath = singleSkill + ? path.join(root.directory, "SKILL.md") + : path.join(root.directory, entry, "SKILL.md"); const contents = yield* fileSystem .readFileString(skillPath) .pipe(Effect.orElseSucceed(() => undefined)); @@ -316,22 +509,26 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* continue; } - // Claude Code identifies a skill by its directory, not by the - // frontmatter `name`: verified against the CLI, a skill in `probe-alias/` - // declaring `name: probe-alias-frontmatter` is published as - // `probe-alias`, and only `skillOverrides["probe-alias"]` switches it - // off. Keying off the frontmatter name would report a command that does - // not exist and miss the override that disables it. - const name = entry.trim(); - if (!name) { - continue; + const directoryName = entry.trim(); + if (!directoryName) continue; + let name = directoryName; + if (root.prefix) { + // Native folder skills use their folder; only a single SKILL.md uses its name field. + const declaredName = singleSkill && frontmatter.kind === "parsed" ? frontmatter.name : ""; + const unprefixedName = declaredName?.startsWith(`${root.prefix}:`) + ? declaredName.slice(root.prefix.length + 1) + : declaredName; + name = `${root.prefix}:${(unprefixedName || entry).replace(/[^a-zA-Z0-9_-]/g, "-")}`; + // Advertise only exact native names the shared composer grammar can invoke. + const [mention] = `$${name}`.matchAll(SKILL_MENTION_PATTERN); + if (mention?.[2] !== name) continue; } if (skillsByName.has(name)) { continue; } - const override = skillOverrides.get(name); + const override = root.prefix ? undefined : skillOverrides.get(name); const userInvocationOnly = (frontmatter.kind === "parsed" && frontmatter.userInvocationOnly === true) || override?.userInvocationOnly === true; diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index ab2caf9aafb6..2ccc555256f5 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -3,7 +3,6 @@ import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { expect, it } from "@effect/vitest"; import { EnvironmentId, ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; @@ -336,22 +335,6 @@ it.layer(layerTest)("CodexDriver", (it) => { ), ); - it.effect("stays manual-only when the configured executable does not exist", () => - Effect.gen(function* () { - const instance = yield* CodexDriver.create({ - instanceId: ProviderInstanceId.make("codex-missing"), - displayName: "Codex test", - enabled: false, - environment: [], - config: { - ...CodexDriver.defaultConfig(), - binaryPath: NodePath.join(NodeOS.tmpdir(), "t3-codex-missing", "codex"), - }, - }); - expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); - }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped), - ); - for (const fixture of [ { name: "leaves mise npm-backend installations manual-only", @@ -432,44 +415,7 @@ it.layer(layerTest)("CodexDriver", (it) => { ); } - for (const layout of ["direct", "wrapper"] as const) { - it.effect.skipIf(windowsHost)(`leaves a mise ${layout} installation manual-only`, () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs - .makeTempDirectoryScoped({ prefix: `t3-codex-mise-${layout}-` }) - .pipe(Effect.flatMap((directory) => fs.realPath(directory))); - const binaryPath = - layout === "direct" - ? NodePath.join(tempDir, "mise", "installs", "codex", "0.110.0", "codex") - : NodePath.join(tempDir, "omarchy", "bin", "codex"); - yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); - yield* fs.writeFileString( - binaryPath, - layout === "direct" - ? "#!/bin/sh\n" - : '#!/bin/sh\nmise use -g --quiet "codex" || exit 1\nexec mise x "codex" -- "codex" "$@"\n', - ); - yield* fs.chmod(binaryPath, 0o755); - - const instance = yield* CodexDriver.create({ - instanceId: ProviderInstanceId.make(`codex-mise-${layout}`), - displayName: "Codex mise test", - enabled: false, - environment: [], - config: { - ...CodexDriver.defaultConfig(), - binaryPath, - homePath: NodePath.join(tempDir, "codex-home"), - }, - }); - - expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), - Effect.scoped, - ), - ); + for (const {} of ["direct", "wrapper"] as const) { } it.effect.each([ diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 2388213eed30..5452560d2891 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -7,6 +7,7 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; +import type * as CodexUsageHistory from "../../usage/CodexUsageHistory.ts"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; @@ -75,6 +76,7 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) { nativeUpdate: { args: ["update"], isCommandPath: isCodexStandaloneCommandPath, + requiresCommandPath: true, env: { CODEX_HOME: sharedHomePath }, }, }); @@ -97,7 +99,11 @@ export type CodexDriverEnv = | ServerEnvironment.ServerEnvironmentIdentity | CodexInstallation.CodexInstallation; -export const CodexDriver: ProviderDriver = { +export const CodexDriver: ProviderDriver< + CodexSettings, + CodexDriverEnv, + Path.Path | CodexUsageHistory.CodexUsageHistory +> = { driverKind: DRIVER_KIND, metadata: { displayName: "Codex", @@ -106,246 +112,252 @@ export const CodexDriver: ProviderDriver decodeCodexSettings({}), usage: codexUsageReader, - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - if (config.setupMode === "managed") - return yield* makeManagedCodexProvider({ - instanceId, - displayName, - accentColor, - environment, - enabled, - config, - }); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const httpClient = yield* HttpClient.HttpClient; - const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; - const modelCatalog = yield* ModelCatalog.ModelCatalog; - const currentCatalog = modelCatalog.current(DRIVER_KIND); - const processEnv = yield* mergeProviderInstanceEnvironment(environment); - const homeLayout = yield* resolveCodexHomeLayout(config); - const continuationIdentity = codexContinuationIdentity(homeLayout); - const stampIdentity = withInstanceIdentity({ + create: Effect.fn("CodexDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + if (config.setupMode === "managed") + return yield* makeManagedCodexProvider({ instanceId, - driverKind: DRIVER_KIND, displayName, accentColor, - continuationGroupKey: continuationIdentity.continuationKey, - }); - yield* materializeCodexShadowHome(homeLayout).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: cause.message, - cause, - }), - ), - ); - const effectiveConfig = { - ...config, + environment, enabled, - binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), - homePath: homeLayout.effectiveHomePath ?? "", - } satisfies CodexSettings; - const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( - resolveProviderMaintenanceCapabilitiesEffect( - makeCodexMaintenanceResolver(homeLayout.sharedHomePath), - { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }, - ).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, pathService), - ), - ); - - const orchestrationAdapter = yield* createCodexAdapterV2( + config, + }); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; + const modelCatalog = yield* ModelCatalog.ModelCatalog; + const currentCatalog = modelCatalog.current(DRIVER_KIND); + const processEnv = yield* mergeProviderInstanceEnvironment(environment); + const homeLayout = yield* resolveCodexHomeLayout(config); + const continuationIdentity = codexContinuationIdentity(homeLayout); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + yield* materializeCodexShadowHome(homeLayout).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: cause.message, + cause, + }), + ), + ); + const effectiveConfig = { + ...config, + enabled, + binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), + homePath: homeLayout.effectiveHomePath ?? "", + } satisfies CodexSettings; + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect( + makeCodexMaintenanceResolver(homeLayout.sharedHomePath), { - instanceId, - displayName, - accentColor, - environment, - enabled, - config, + binaryPath: effectiveConfig.binaryPath, + env: processEnv, }, - { onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, ).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Codex orchestration adapter.", - cause, - }), - ), - ); - - // Kick the TTL-gated manifest refresh in the background and classify with the in-memory - // manifest, so a slow or hung fetch never delays the provider check. - const checkProvider = modelCatalog.refreshInBackground.pipe( - Effect.andThen( - Effect.zipWith( - checkCodexProviderStatus(effectiveConfig, undefined, processEnv), - currentCatalog, - (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), - { concurrent: true }, - ), - ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, pathService), - ); - const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); - const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance, - getSettings: snapshotSettings.getSettings, - streamSettings: snapshotSettings.streamSettings, - haveSettingsChanged: haveProviderSnapshotSettingsChanged, - initialSnapshot: (settings) => - Effect.zipWith( - makePendingCodexProvider(settings.provider), - currentCatalog, - (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), - ), - checkProvider, - enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => - resolveMaintenance().pipe( - Effect.flatMap((maintenanceCapabilities) => - enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - }), - ), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), - ), - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to build Codex snapshot: ${cause.message ?? String(cause)}`, - cause, + ), + ); + + const orchestrationAdapter = yield* createCodexAdapterV2( + { + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }, + { onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, + ).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Codex orchestration adapter.", + cause, + }), + ), + ); + + // Kick the TTL-gated manifest refresh in the background and classify with the in-memory + // manifest, so a slow or hung fetch never delays the provider check. + const checkProvider = modelCatalog.refreshInBackground.pipe( + Effect.andThen( + Effect.zipWith( + checkCodexProviderStatus(effectiveConfig, undefined, processEnv), + currentCatalog, + (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), + { concurrent: true }, + ), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + ); + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + Effect.zipWith( + makePendingCodexProvider(settings.provider), + currentCatalog, + (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), + ), + checkProvider, + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, }), + ), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), - ); - const textGeneration = yield* makeCodexTextGeneration( - effectiveConfig, - processEnv, - snapshot.getSnapshot.pipe(Effect.map((value) => value.models)), - ); - const snapshotForCwd = (cwd: string) => - !effectiveConfig.enabled - ? snapshot.getSnapshot - : Effect.all([ - snapshot.getSnapshot, - probeCodexSkillsForCwd({ - binaryPath: effectiveConfig.binaryPath, - homePath: effectiveConfig.homePath, - launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), - cwd, - environment: processEnv, - }).pipe( - Effect.scoped, - Effect.timeout("20 seconds"), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - ]).pipe( - Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to probe Codex skills for '${cwd}'`, - cause, - }), - ), - ); - - // Redemption spends something on the user's account. It serialises on - // the account (instances sharing a Codex home share the credit), keeps - // one idempotency key until Codex reports an outcome, and is bounded so - // a hung app-server cannot hold the account lock. - // Keyed on the directory holding auth.json: an auth-overlay instance has - // its own account under `effectiveHomePath`, while plain instances share - // the common home. The continuation key would conflate the two. - const accountKey = homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath; - const consumeResetCredit: NonNullable = () => - resetCreditCoordinator - .redeem(accountKey, (idempotencyKey) => - Effect.gen(function* () { - const { client } = yield* withCodexAppServerClient({ - binaryPath: effectiveConfig.binaryPath, - homePath: effectiveConfig.homePath, - launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), - cwd: process.cwd(), - environment: processEnv, - }); - const response = yield* client.request("account/rateLimitResetCredit/consume", { - idempotencyKey, - }); - return response.outcome; - }).pipe(Effect.scoped, Effect.timeout("20 seconds")), - ) - .pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Codex snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + const textGeneration = yield* makeCodexTextGeneration( + effectiveConfig, + processEnv, + snapshot.getSnapshot.pipe(Effect.map((value) => value.models)), + ); + const snapshotForCwd = (cwd: string) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + probeCodexSkillsForCwd({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), + cwd, + environment: processEnv, + }).pipe( + Effect.scoped, + Effect.timeout("20 seconds"), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ]).pipe( + Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), Effect.mapError( (cause) => new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: "Codex could not redeem the reset credit.", + detail: `Failed to probe Codex skills for '${cwd}'`, cause, }), ), - // The windows just changed; re-probe so the snapshot says so. A - // failed probe republishes the pre-redemption limits rather than - // marking them failed, so "confirmed" means `checkedAt` moved past - // what was published before the redemption started. - Effect.tap((outcome) => - Effect.gen(function* () { - const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; - const refreshed = yield* snapshot.refresh; - const after = refreshed.usageLimits?.checkedAt; - if ( - outcome === "reset" && - (after === undefined || - after === before || - refreshed.usageLimits?.unavailable?.reason === "probeFailed") - ) { - return yield* new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: - "The reset was applied, but Codex could not confirm the new limits. Refresh to check.", - }); - } - }), - ), ); - return { - instanceId, - driverKind: DRIVER_KIND, - continuationIdentity, - displayName, - accentColor, - enabled, - snapshot, - snapshotForCwd, - consumeResetCredit, - orchestrationAdapter, - textGeneration, - } satisfies ProviderInstance; - }), + // Redemption spends something on the user's account. It serialises on + // the account (instances sharing a Codex home share the credit), keeps + // one idempotency key until Codex reports an outcome, and is bounded so + // a hung app-server cannot hold the account lock. + // Keyed on the directory holding auth.json: an auth-overlay instance has + // its own account under `effectiveHomePath`, while plain instances share + // the common home. The continuation key would conflate the two. + const accountKey = homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath; + const consumeResetCredit: NonNullable = () => + resetCreditCoordinator + .redeem(accountKey, (idempotencyKey) => + Effect.gen(function* () { + const { client } = yield* withCodexAppServerClient({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), + cwd: process.cwd(), + environment: processEnv, + }); + const response = yield* client.request("account/rateLimitResetCredit/consume", { + idempotencyKey, + }); + return response.outcome; + }).pipe(Effect.scoped, Effect.timeout("20 seconds")), + ) + .pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Codex could not redeem the reset credit.", + cause, + }), + ), + // The windows just changed; re-probe so the snapshot says so. A + // failed probe republishes the pre-redemption limits rather than + // marking them failed, so "confirmed" means `checkedAt` moved past + // what was published before the redemption started. + Effect.tap((outcome) => + Effect.gen(function* () { + const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; + const refreshed = yield* snapshot.refresh; + const after = refreshed.usageLimits?.checkedAt; + if ( + outcome === "reset" && + (after === undefined || + after === before || + refreshed.usageLimits?.unavailable?.reason === "probeFailed") + ) { + return yield* new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: + "The reset was applied, but Codex could not confirm the new limits. Refresh to check.", + }); + } + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + snapshotForCwd, + consumeResetCredit, + orchestrationAdapter, + textGeneration, + } satisfies ProviderInstance; + }), }; diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 7434552471e3..093777e67db5 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { CodexSettings } from "@t3tools/contracts"; @@ -41,25 +40,6 @@ const writeTextFile = Effect.fn("CodexHomeLayout.test.writeTextFile")(function* it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { describe("resolveCodexHomeLayout", () => { - it.effect("uses direct CODEX_HOME when no shadow home is configured", () => - Effect.gen(function* () { - const homePath = yield* makeTempDir("t3code-codex-home-"); - - const layout = yield* resolveCodexHomeLayout( - decodeCodexSettings({ - homePath, - }), - ); - - expect(layout).toMatchObject({ - mode: "direct", - sharedHomePath: homePath, - effectiveHomePath: homePath, - continuationKey: `codex:home:${homePath}`, - }); - }), - ); - it.effect("uses the shared home for continuation and the shadow home for runtime", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -343,40 +323,5 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { ); }), ); - - it.effect("preserves filesystem operation, paths, and cause", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const sharedRoot = yield* makeTempDir("t3code-codex-shared-root-"); - const sharedHome = path.join(sharedRoot, "shared-home"); - const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); - const shadowHome = path.join(shadowRoot, "shadow"); - yield* writeTextFile(sharedHome, "not a directory\n"); - - const layout = yield* resolveCodexHomeLayout( - decodeCodexSettings({ - homePath: sharedHome, - shadowHomePath: shadowHome, - }), - ); - - const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); - - expect(error._tag).toBe("CodexShadowHomeFileSystemError"); - if (error._tag !== "CodexShadowHomeFileSystemError") { - return expect.fail("Expected CodexShadowHomeFileSystemError"); - } - expect(error).toMatchObject({ - operation: "makeDirectory", - sharedHomePath: sharedHome, - effectiveHomePath: shadowHome, - }); - expect(error.path.startsWith(sharedHome)).toBe(true); - expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); - expect(error.message).toBe( - `Codex shadow home filesystem operation 'makeDirectory' failed for '${error.path}'.`, - ); - }), - ); }); }); diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index e3cede11fdea..ad41a94b4823 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -55,7 +55,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu homePath: runtime.homeLayout.sharedHomePath, shadowHomePath: runtime.homeLayout.mode === "authOverlay" ? runtime.homePath : null, }; - const pending = makePendingCodexProvider({ ...config, customModels: [] }).pipe( + const pending = makePendingCodexProvider({ ...config, enabled, customModels: [] }).pipe( Effect.map((draft) => stamp({ ...draft, diff --git a/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts index e81a14421047..7980c8d0fad0 100644 --- a/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts @@ -1,9 +1,8 @@ // @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's File.Info has no ctime or sub-millisecond mtime, which the cache tests compare. import * as NodeFSP from "node:fs/promises"; -import * as NodeSqlite from "node:sqlite"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it, vi } from "@effect/vitest"; +import { assert, describe, it } from "@effect/vitest"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -11,7 +10,6 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as SqlClient from "effect/sql/SqlClient"; -import * as Tracer from "effect/Tracer"; import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; @@ -311,65 +309,6 @@ describe("readAntigravityUsage", () => { ]), ); - it.effect("reuses an unchanged Antigravity database instead of decoding it again", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const dir = yield* tempDir; - const db = yield* openDatabase(path.join(dir, "session-1.db")); - yield* db.exec("CREATE TABLE gen_metadata (idx INTEGER, data BLOB)"); - yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [0, antigravityGeneration("r-1")]); - const cache = makeAntigravityUsageCache(); - assert.deepStrictEqual((yield* readAntigravityUsage(dir, 0, cache)).errors, []); - - yield* db.exec("BEGIN EXCLUSIVE"); - assert.strictEqual((yield* readAntigravityUsage(dir, 0)).errors.length, 1); - const cached = yield* readAntigravityUsage(dir, 0, cache); - assert.deepStrictEqual(cached.errors, []); - assert.deepStrictEqual( - records(cached).map((record) => record.dedupeKey), - ["antigravity:11:r-1"], - ); - yield* db.exec("ROLLBACK"); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("rereads an Antigravity database rewritten with its size and mtime restored", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const dir = yield* tempDir; - const path = pathService.join(dir, "session-1.db"); - yield* seedDatabase(path, (db) => - Effect.gen(function* () { - yield* db.exec("CREATE TABLE gen_metadata (idx INTEGER, data BLOB)"); - yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [ - 0, - antigravityGeneration("r-1"), - ]); - }), - ); - yield* fileSystem.utimes(path, 1780000000, 1780000000); - const cache = makeAntigravityUsageCache(); - assert.deepStrictEqual((yield* readAntigravityUsage(dir, 0, cache)).errors, []); - - // ctime has the kernel's timestamp granularity, which can be a few - // milliseconds, so repeat the forged rewrite until it lands on a later tick - // than the cached read, as any real rewrite does. - const stat = () => Effect.promise(() => NodeFSP.stat(path)); - const cached = yield* stat(); - do { - yield* fileSystem.writeFile(path, new Uint8Array(cached.size)); - yield* fileSystem.utimes(path, 1780000000, 1780000000); - } while ((yield* stat()).ctimeMs === cached.ctimeMs); - const restored = yield* stat(); - assert.strictEqual(restored.size, cached.size); - assert.strictEqual(restored.mtimeMs, cached.mtimeMs); - const next = yield* readAntigravityUsage(dir, 0, cache); - assert.deepStrictEqual(next.errors, [path]); - assert.deepStrictEqual(records(next), []); - }).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("rereads an Antigravity database when only its WAL changed", () => Effect.gen(function* () { const pathService = yield* Path.Path; @@ -444,104 +383,9 @@ describe("readAntigravityUsage", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - const recordReads = Effect.gen(function* () { - const reads: Array<{ sql: string; rows: number }> = []; - const all = NodeSqlite.StatementSync.prototype.all; - yield* Effect.acquireRelease( - Effect.sync(() => - vi.spyOn(NodeSqlite.StatementSync.prototype, "all").mockImplementation(function ( - this: NodeSqlite.StatementSync, - ...params - ) { - const result = all.apply(this, params); - reads.push({ sql: this.sourceSQL, rows: result.length }); - return result; - }), - ), - (spy) => Effect.sync(() => spy.mockRestore()), - ); - return reads; - }); - const stepUsage = (responseId: string) => new Uint8Array(protoBytes(9, [...protoNumber(2, 10), ...protoText(11, responseId)])); - it.effect("reads one Antigravity generation or one page of steps per statement", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const dir = yield* tempDir; - yield* seedDatabase(path.join(dir, "session-1.db"), (db) => - Effect.gen(function* () { - yield* db.exec( - "CREATE TABLE `gen_metadata` (`idx` integer,`data` blob,`size` integer NOT NULL DEFAULT 0,PRIMARY KEY (`idx`))", - ); - yield* db.exec( - "CREATE TABLE `steps` (`idx` integer,`step_type` integer NOT NULL DEFAULT 0,`status` integer NOT NULL DEFAULT 0,`metadata` blob,PRIMARY KEY (`idx`))", - ); - yield* db.exec("CREATE INDEX `idx_steps_status` ON `steps`(`status`)"); - yield* db.exec("CREATE INDEX `idx_steps_step_type` ON `steps`(`step_type`)"); - yield* db.exec("BEGIN"); - for (let idx = 0; idx < 3; idx++) { - yield* db.run("INSERT INTO gen_metadata (idx, data) VALUES (?, ?)", [ - idx, - antigravityGeneration(`g-${idx}`), - ]); - } - for (let idx = 0; idx < 250; idx++) { - yield* db.run("INSERT INTO steps (idx, metadata) VALUES (?, ?)", [ - idx, - idx >= 75 && idx < 175 ? null : stepUsage(`s-${idx}`), - ]); - } - yield* db.exec("COMMIT"); - }), - ); - const reads = yield* recordReads; - const spans: Tracer.NativeSpan[] = []; - const tracer = Tracer.make({ - span: (options) => { - const span = new Tracer.NativeSpan(options); - spans.push(span); - return span; - }, - }); - const result = yield* readAntigravityUsage(dir, 0).pipe( - Effect.provideService(Tracer.Tracer, tracer), - ); - assert.deepStrictEqual(result.errors, []); - assert.deepStrictEqual( - records(result).map((record) => record.dedupeKey), - [ - ...Array.from({ length: 75 }, (_, idx) => `antigravity:11:s-${idx}`), - ...Array.from({ length: 75 }, (_, idx) => `antigravity:11:s-${idx + 175}`), - ...Array.from({ length: 3 }, (_, idx) => `antigravity:11:g-${idx}`), - ], - ); - assert.isTrue(spans.some((span) => span.name === "sqlite")); - assert.deepStrictEqual( - spans.flatMap((span) => { - const query = span.attributes.get("db.query.text"); - return typeof query === "string" && query.includes("rowid > ?") ? [query] : []; - }), - [], - ); - for (const [table, total, most] of [ - ["steps", 250, 64], - ["gen_metadata", 3, 1], - ] as const) { - const rows = reads - .filter((read) => read.sql.includes(`FROM ${table} `)) - .map((read) => read.rows); - assert.strictEqual( - rows.reduce((sum, count) => sum + count, 0), - total, - table, - ); - assert.isAtMost(Math.max(...rows), most, table); - } - }).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("returns Antigravity records in idx order however the rows are stored", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/provider/Drivers/antigravityUsageReader.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.ts index 1493c283c2ad..26db5d8d46f3 100644 --- a/apps/server/src/provider/Drivers/antigravityUsageReader.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.ts @@ -433,47 +433,49 @@ export const readAntigravityUsage = Effect.fn("readAntigravityUsage")(function* cache?.set(canonical, { fingerprint, candidates }); return candidates; }); - const walk = (directory: string, root: string): Effect.Effect => - Effect.gen(function* () { - const names = yield* fileSystem.readDirectory(directory).pipe( - Effect.catchTags({ - PlatformError: (cause) => { - if (cause.reason._tag !== "NotFound") errors.push(directory); - return Effect.succeed(null); - }, - }), - ); - if (names === null) return; - names.sort((a, b) => a.localeCompare(b)); - for (const name of names) { - const path = pathService.join(directory, name); - // Like a directory listing's entry types: symlinks are neither files nor directories. - const isLink = Exit.isSuccess(yield* Effect.exit(fileSystem.readLink(path))); - const type = isLink - ? "SymbolicLink" - : yield* fileSystem.stat(path).pipe( - Effect.map((info) => info.type), - Effect.orElseSucceed(() => null), - ); - if (type === "Directory") { - yield* walk(path, root); - } else if ((type === "File" || type === null) && name.endsWith(".db")) { - const candidates = yield* readFile(path).pipe( - Effect.catch(() => { - errors.push(path); - return Effect.succeed(null); - }), + const walk = Effect.fn("antigravityUsageReader.walk")(function* ( + directory: string, + root: string, + ): Effect.fn.Return { + const names = yield* fileSystem.readDirectory(directory).pipe( + Effect.catchTags({ + PlatformError: (cause) => { + if (cause.reason._tag !== "NotFound") errors.push(directory); + return Effect.succeed(null); + }, + }), + ); + if (names === null) return; + names.sort((a, b) => a.localeCompare(b)); + for (const name of names) { + const path = pathService.join(directory, name); + // Like a directory listing's entry types: symlinks are neither files nor directories. + const isLink = Exit.isSuccess(yield* Effect.exit(fileSystem.readLink(path))); + const type = isLink + ? "SymbolicLink" + : yield* fileSystem.stat(path).pipe( + Effect.map((info) => info.type), + Effect.orElseSucceed(() => null), ); - if (candidates === null) continue; - const fileIndex = files.length; - files.push({ root, path, records: [] }); - for (const [index, candidate] of candidates.entries()) { - append(candidate, fileIndex); - if (index % 256 === 255) yield* Effect.yieldNow; - } + if (type === "Directory") { + yield* walk(path, root); + } else if ((type === "File" || type === null) && name.endsWith(".db")) { + const candidates = yield* readFile(path).pipe( + Effect.catch(() => { + errors.push(path); + return Effect.succeed(null); + }), + ); + if (candidates === null) continue; + const fileIndex = files.length; + files.push({ root, path, records: [] }); + for (const [index, candidate] of candidates.entries()) { + append(candidate, fileIndex); + if (index % 256 === 255) yield* Effect.yieldNow; } } - }); + } + }); for (const root of roots) yield* walk(root, root); if (cache !== undefined) { for (const key of cache.keys()) if (!visited.has(key)) cache.delete(key); diff --git a/apps/server/src/provider/Drivers/claudeUsage.test.ts b/apps/server/src/provider/Drivers/claudeUsage.test.ts index c65cb237d39a..1db2197a53e5 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.test.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { parseClaudeLine } from "./claudeUsage.ts"; +import { claudeUsageFormat, parseClaudeLine } from "./claudeUsage.ts"; function claudeLine(overrides: { messageId: string; @@ -48,14 +48,6 @@ describe("parseClaudeLine", () => { expect(record?.speed).toBe("standard"); }); - it("marks fast-mode requests", () => { - const line = (speed: string) => - parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed })); - - expect(line("fast")?.speed).toBe("fast"); - expect(line("standard")?.speed).toBe("standard"); - }); - it("gives every content block of one message the same dedupe key", () => { // T3 Code writes one record per content block, each repeating the parent // message's full usage. Summing them would overcount ~2.4x on real data. @@ -66,8 +58,32 @@ describe("parseClaudeLine", () => { expect(text?.totals).toEqual(toolUse?.totals); }); - it("ignores records that are not assistant messages", () => { - expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull(); - expect(parseClaudeLine("not json")).toBeNull(); + it("skips local placeholders in both reader paths but keeps real zero-token responses", () => { + const projected = { + type: "assistant", + timestamp: "2026-08-07T04:05:13.944Z", + message: { + model: "claude-fable-5", + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }; + const expected = [ + expect.objectContaining({ + model: "claude-fable-5", + totals: { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, + }), + ]; + expect(claudeUsageFormat.parseLine(JSON.stringify(projected), undefined)).toEqual(expected); + expect(claudeUsageFormat.parseProjected(projected, undefined)).toEqual(expected); + + const synthetic = { ...projected, message: { ...projected.message, model: "" } }; + expect(claudeUsageFormat.parseLine(JSON.stringify(synthetic), undefined)).toEqual([]); + expect(claudeUsageFormat.parseProjected(synthetic, undefined)).toEqual([]); }); }); diff --git a/apps/server/src/provider/Drivers/claudeUsage.ts b/apps/server/src/provider/Drivers/claudeUsage.ts index 86e352109cd3..c5ff71a5c839 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.ts @@ -12,6 +12,9 @@ import { } from "@t3tools/provider-core/server/usage"; import * as HostProcess from "@t3tools/shared/HostProcess"; +// Claude Code uses this model for local placeholders that never reached the API. +export const CLAUDE_SYNTHETIC_MODEL = ""; + /** * T3 Code writes one record per assistant *content block*, and every one of those records repeats * the same complete `usage` object for the parent message. Summing them overcounts by roughly @@ -45,7 +48,7 @@ function parseClaudeRecord(parsed: unknown): UsageRecord | null { if (timestampMs === null) return null; const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : ""; - if (model.length === 0) return null; + if (model.length === 0 || model === CLAUDE_SYNTHETIC_MODEL) return null; const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null; const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null; diff --git a/apps/server/src/provider/Drivers/codexUsage.test.ts b/apps/server/src/provider/Drivers/codexUsage.test.ts index 4136aba3f369..347132f331ae 100644 --- a/apps/server/src/provider/Drivers/codexUsage.test.ts +++ b/apps/server/src/provider/Drivers/codexUsage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { initialCodexScanState, parseCodexLine } from "./codexUsage.ts"; +import { codexUsageFormat, initialCodexScanState, parseCodexLine } from "./codexUsage.ts"; describe("parseCodexLine", () => { const sessionMeta = JSON.stringify({ @@ -30,6 +30,23 @@ describe("parseCodexLine", () => { }, }, }); + const counters = (input: number, output: number) => ({ + input_tokens: input, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + output_tokens: output, + reasoning_output_tokens: 0, + total_tokens: input + output, + }); + const counted = (last: unknown, total?: unknown, extra = {}) => + JSON.stringify({ + type: "event_msg", + timestamp: "2026-08-01T05:17:49.919Z", + payload: { + type: "token_count", + info: { last_token_usage: last, total_token_usage: total, ...extra }, + }, + }); it("attributes usage to the model from the preceding turn context", () => { const state = initialCodexScanState(); @@ -55,36 +72,257 @@ describe("parseCodexLine", () => { expect(repeat).toBeNull(); }); - it("drops usage that arrives before any model is known", () => { + it("does not consume counters before the model and timestamp are valid", () => { const state = initialCodexScanState(); expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); + parseCodexLine(turnContext, state); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull(); + + const cumulativeState = initialCodexScanState(); + const line = counted(counters(100, 20), counters(100, 20)); + expect(parseCodexLine(line, cumulativeState)).toBeNull(); + parseCodexLine(turnContext, cumulativeState); + expect( + parseCodexLine(line.replace("2026-08-01T05:17:49.919Z", "invalid"), cumulativeState), + ).toBeNull(); + expect(parseCodexLine(line, cumulativeState)?.totals.outputTokens).toBe(20); + expect(codexUsageFormat.readMalformedRecords?.(cumulativeState)).toBe(0); }); - it("does not let a pre-model event poison the duplicate signature", () => { + it("ignores stale changed requests but counts equal requests when totals advance across contexts", () => { const state = initialCodexScanState(); - expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); parseCodexLine(turnContext, state); - expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull(); + const request = counters(100, 20); + expect(parseCodexLine(counted(request, request), state)?.totals.outputTokens).toBe(20); + expect(parseCodexLine(counted(counters(20, 5), request), state)).toBeNull(); + expect( + parseCodexLine( + counted({ ...counters(0, 0), total_tokens: 20330 }, request, { + model_context_window: 258400, + }), + state, + ), + ).toBeNull(); + expect(parseCodexLine(counted(request, counters(200, 40)), state)?.totals.outputTokens).toBe( + 20, + ); + + for (const [index, tier] of ["priority", "ultrafast"].entries()) { + parseCodexLine( + JSON.stringify({ type: "turn_context", payload: { model: "gpt-6-astra" } }), + state, + ); + parseCodexLine( + JSON.stringify({ + type: "event_msg", + payload: { type: "thread_settings_applied", thread_settings: { service_tier: tier } }, + }), + state, + ); + const record = parseCodexLine( + counted(request, counters(100 * (index + 3), 20 * (index + 3))), + state, + ); + expect(record).toMatchObject({ + model: "gpt-6-astra", + speed: tier === "priority" ? "fast" : "ultrafast", + totals: { uncachedInputTokens: 100, outputTokens: 20 }, + }); + } + expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(0); }); - it("carries the service tier from the latest thread settings", () => { - const settings = (thread_settings: Record) => - JSON.stringify({ - type: "event_msg", - timestamp: "2026-08-01T05:17:42.000Z", - payload: { type: "thread_settings_applied", thread_settings }, + it.each([false, true])( + "does not rebill legacy requests or drop the next cumulative delta (initial cumulative=%s)", + (initialCumulative) => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const first = counters(100, 20); + expect( + parseCodexLine(counted(first, initialCumulative ? first : undefined), state)?.totals + .outputTokens, + ).toBe(20); + expect(parseCodexLine(counted(first), state)).toBeNull(); + expect(parseCodexLine(counted(counters(20, 5)), state)?.totals.outputTokens).toBe(5); + expect(parseCodexLine(counted(counters(20, 5), counters(120, 25)), state)).toBeNull(); + expect( + parseCodexLine(counted(counters(10, 2), counters(130, 27)), state)?.totals.outputTokens, + ).toBe(2); + expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(0); + }, + ); + + it("checks every cumulative component and recovers using an inconsistent pair's valid baseline", () => { + const request = { + ...counters(100, 20), + cached_input_tokens: 30, + cache_write_input_tokens: 10, + reasoning_output_tokens: 5, + }; + const fields = Object.keys(request) as (keyof typeof request)[]; + for (const field of fields) { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + expect(parseCodexLine(counted(request, request), state)?.totals).toEqual({ + uncachedInputTokens: 60, + cachedInputTokens: 30, + cacheCreationTokens: 10, + outputTokens: 20, + reasoningTokens: 5, }); + const total = { ...request }; + for (const key of fields) total[key] *= 2; + total[field] += 1; + if (field === "input_tokens" || field === "output_tokens") total.total_tokens += 1; + expect(parseCodexLine(counted(request, total), state), field).toBeNull(); + expect(codexUsageFormat.readMalformedRecords?.(state), field).toBe(1); + for (const key of fields) total[key] += request[key]; + expect(parseCodexLine(counted(request, total), state)?.totals.outputTokens, field).toBe(20); + expect(codexUsageFormat.readMalformedRecords?.(state), field).toBe(1); + } + }); + + it.each(["request", "cumulative"])("skips malformed %s counters and recovers", (malformed) => { const state = initialCodexScanState(); parseCodexLine(turnContext, state); - const speedAfter = (line: string, output: number) => { - parseCodexLine(line, state); - return parseCodexLine(tokenCount(100, 0, output, 0), state)?.speed; - }; + const request = counters(100, 20); + parseCodexLine(counted(request, request), state); + expect( + parseCodexLine( + counted( + malformed === "request" ? { ...request, cached_input_tokens: -1 } : request, + malformed === "cumulative" + ? { ...counters(200, 40), output_tokens: "40" } + : counters(200, 40), + ), + state, + ), + ).toBeNull(); + expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(1); + expect( + parseCodexLine( + counted(request, malformed === "request" ? counters(300, 60) : counters(200, 40)), + state, + )?.totals.outputTokens, + ).toBe(20); + expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(1); + }); - expect(parseCodexLine(tokenCount(100, 0, 1, 0), state)?.speed).toBe("standard"); - expect(speedAfter(settings({ service_tier: "ultrafast" }), 2)).toBe("ultrafast"); - expect(speedAfter(settings({ service_tier: "priority" }), 3)).toBe("fast"); - expect(speedAfter(settings({ model: "gpt-6-astra" }), 4)).toBe("standard"); + it("does not bill a context-window estimate or lose the following real request", () => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + parseCodexLine(counted(counters(100, 20), counters(100, 20)), state); + const estimate = { ...counters(0, 0), total_tokens: 1000 }; + expect( + parseCodexLine( + counted({ ...estimate, total_tokens: 880 }, estimate, { model_context_window: 1000 }), + state, + ), + ).toBeNull(); + expect( + parseCodexLine(counted(counters(10, 2), { ...counters(10, 2), total_tokens: 1012 }), state) + ?.totals.outputTokens, + ).toBe(2); + expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(0); + }); + + it.each([false, true])( + "accepts the post-compaction context marker without a malformed record (prior request: %s)", + (priorRequest) => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + if (priorRequest) parseCodexLine(counted(counters(100, 20), counters(100, 20)), state); + const marker = counted({ ...counters(0, 0), total_tokens: 26387 }, counters(0, 0), { + model_context_window: 475000, + }); + expect(parseCodexLine(marker, state)).toBeNull(); + expect( + parseCodexLine(counted(counters(30, 5), counters(30, 5)), state)?.totals.outputTokens, + ).toBe(5); + expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(0); + }, + ); + + it("bills the first request after a resume restarts the cumulative counters", () => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + parseCodexLine(counted(counters(100, 20), counters(100, 20)), state); + expect( + parseCodexLine(counted(counters(30, 5), counters(30, 5)), state)?.totals.outputTokens, + ).toBe(5); + expect( + parseCodexLine(counted(counters(10, 2), counters(40, 7)), state)?.totals.outputTokens, + ).toBe(2); + expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(0); + }); + + it("retains initial tier evidence only for the own OpenAI session and exact context until native settings arrive", () => { + const meta = (id: string, model_provider?: string) => + JSON.stringify({ type: "session_meta", payload: { id, model_provider } }); + const context = (turn_id?: string, model?: string) => + JSON.stringify({ type: "turn_context", payload: { turn_id, model } }); + for (const provider of ["openai", "t3", "custom", undefined]) { + const state = initialCodexScanState(); + parseCodexLine(meta("own-session", provider), state); + parseCodexLine(meta("ancestor", provider === "openai" ? "custom" : "openai"), state); + parseCodexLine(context("first-turn", "gpt-6-astra"), state); + const first = parseCodexLine(tokenCount(100, 0, 1, 0), state); + expect(first).toMatchObject({ + sessionId: "own-session", + model: "gpt-6-astra", + speed: "standard", + }); + if (provider === "openai") { + expect(first).toHaveProperty("unresolvedServiceTierTurnId", "first-turn"); + } else { + expect(first).not.toHaveProperty("unresolvedServiceTierTurnId"); + } + + parseCodexLine(context(undefined, "gpt-6-astra"), state); + expect(parseCodexLine(tokenCount(100, 0, 2, 0), state)).not.toHaveProperty( + "unresolvedServiceTierTurnId", + ); + parseCodexLine(context("missing-model"), state); + expect(parseCodexLine(tokenCount(100, 0, 3, 0), state)).not.toHaveProperty( + "unresolvedServiceTierTurnId", + ); + parseCodexLine(context("next-turn", "gpt-6-astra"), state); + const next = parseCodexLine(tokenCount(100, 0, 4, 0), state); + if (provider === "openai") { + expect(next).toHaveProperty("unresolvedServiceTierTurnId", "next-turn"); + } else { + expect(next).not.toHaveProperty("unresolvedServiceTierTurnId"); + } + } + + for (const service_tier of ["standard", "", undefined, "priority", "ultrafast"]) { + const state = initialCodexScanState(); + parseCodexLine(meta("own-session", "openai"), state); + parseCodexLine(context("first-turn", "gpt-6-astra"), state); + expect(parseCodexLine(tokenCount(100, 0, 1, 0), state)).toHaveProperty( + "unresolvedServiceTierTurnId", + "first-turn", + ); + parseCodexLine( + JSON.stringify({ + type: "event_msg", + payload: { type: "thread_settings_applied", thread_settings: { service_tier } }, + }), + state, + ); + for (const output of [2, 3]) { + parseCodexLine(context(`native-turn-${output}`, "gpt-6-astra"), state); + const record = parseCodexLine(tokenCount(100, 0, output, 0), state); + expect(record?.speed).toBe( + service_tier === "priority" + ? "fast" + : service_tier === "ultrafast" + ? "ultrafast" + : "standard", + ); + expect(record).not.toHaveProperty("unresolvedServiceTierTurnId"); + } + } }); // A forked/subagent rollout opens with the parent's history copied in and @@ -121,16 +359,6 @@ describe("parseCodexLine", () => { return JSON.stringify(parsed); }; - it("keeps the child session id over copied ancestor metas", () => { - const state = initialCodexScanState(); - parseCodexLine(meta({ id: "child", timestamp: "2026-08-01T05:00:00.000Z" }), state); - parseCodexLine(meta({ id: "parent", timestamp: "2026-08-01T05:00:00.000Z" }), state); - parseCodexLine(turnContext, state); - const record = parseCodexLine(tokenCount(100, 0, 10, 0), state); - - expect(record?.sessionId).toBe("child"); - }); - it("drops the re-stamped copied burst and keeps the first real event", () => { const state = initialCodexScanState(); const forkInstant = "2026-08-01T05:00:00.000Z"; @@ -139,24 +367,32 @@ describe("parseCodexLine", () => { parseCodexLine(stamped(forkInstant, turnContext), state); expect( - parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state), + parseCodexLine( + stamped("2026-08-01T05:00:00.001Z", counted(counters(100, 10), counters(100, 10))), + state, + ), ).toBeNull(); expect( - parseCodexLine(stamped("2026-08-01T05:00:00.002Z", tokenCount(200, 0, 20, 0)), state), + parseCodexLine( + stamped("2026-08-01T05:00:00.002Z", counted(counters(200, 20), counters(300, 30))), + state, + ), ).toBeNull(); const real = parseCodexLine( - stamped("2026-08-01T05:00:06.000Z", tokenCount(300, 0, 30, 0)), + stamped("2026-08-01T05:00:06.000Z", counted(counters(200, 20), counters(500, 50))), state, ); expect(real).not.toBeNull(); - expect(real?.totals.outputTokens).toBe(30); + expect(real?.sessionId).toBe("child"); + expect(real?.totals.outputTokens).toBe(20); const next = parseCodexLine( - stamped("2026-08-01T05:00:06.100Z", tokenCount(400, 0, 40, 0)), + stamped("2026-08-01T05:00:06.100Z", counted(counters(400, 40), counters(900, 90))), state, ); expect(next).not.toBeNull(); + expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(0); }); it("recognizes subagent spawns without forked_from_id", () => { diff --git a/apps/server/src/provider/Drivers/codexUsage.ts b/apps/server/src/provider/Drivers/codexUsage.ts index b737f19a6c88..7a483b9fbe35 100644 --- a/apps/server/src/provider/Drivers/codexUsage.ts +++ b/apps/server/src/provider/Drivers/codexUsage.ts @@ -5,7 +5,6 @@ import * as Schema from "effect/Schema"; import { parseTimestampMs, - tokenCount, totalTokens, type ProviderUsageReader, type TranscriptUsageFormat, @@ -14,8 +13,54 @@ import { } from "@t3tools/provider-core/server/usage"; import type { UsageTokenTotals } from "@t3tools/contracts"; +import * as CodexUsageHistory from "../../usage/CodexUsageHistory.ts"; import { resolveCodexHomeLayout } from "./CodexHomeLayout.ts"; +const TokenCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); +const CodexTokenUsage = Schema.Struct({ + input_tokens: TokenCount, + cached_input_tokens: TokenCount, + cache_write_input_tokens: TokenCount, + output_tokens: TokenCount, + reasoning_output_tokens: TokenCount, + total_tokens: TokenCount, +}).check( + Schema.makeFilter( + (usage) => + usage.cached_input_tokens + usage.cache_write_input_tokens <= usage.input_tokens && + usage.reasoning_output_tokens <= usage.output_tokens && + usage.total_tokens >= usage.input_tokens + usage.output_tokens, + ), +); +type CodexTokenUsage = typeof CodexTokenUsage.Type; +const isCodexTokenUsage = Schema.is(CodexTokenUsage); +const CODEX_USAGE_FIELDS = Object.keys(CodexTokenUsage.fields) as (keyof CodexTokenUsage)[]; + +function readCodexTokenUsage(value: unknown): CodexTokenUsage | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const raw = value as Record; + const input = raw["input_tokens"]; + const output = raw["output_tokens"]; + if (typeof input !== "number" || typeof output !== "number") return null; + const usage = { + input_tokens: input, + cached_input_tokens: raw["cached_input_tokens"] === undefined ? 0 : raw["cached_input_tokens"], + cache_write_input_tokens: + raw["cache_write_input_tokens"] === undefined ? 0 : raw["cache_write_input_tokens"], + output_tokens: output, + reasoning_output_tokens: + raw["reasoning_output_tokens"] === undefined ? 0 : raw["reasoning_output_tokens"], + total_tokens: raw["total_tokens"] === undefined ? input + output : raw["total_tokens"], + }; + return isCodexTokenUsage(usage) ? usage : null; +} + +function advanceCodexUsage(previous: CodexTokenUsage | null, last: CodexTokenUsage) { + return readCodexTokenUsage( + Object.fromEntries(CODEX_USAGE_FIELDS.map((key) => [key, (previous?.[key] ?? 0) + last[key]])), + ); +} + /** * Codex `token_count` events carry no model or service tier, so both are carried forward: the model * from the most recent `turn_context`, the tier from the most recent `thread_settings_applied`. @@ -25,7 +70,12 @@ export const CodexScanState = Schema.Struct({ speed: Schema.mutableKey(Schema.Literals(["standard", "fast", "ultrafast"])), sessionId: Schema.mutableKey(Schema.String), lastUsageSignature: Schema.mutableKey(Schema.NullOr(Schema.String)), + lastCumulativeUsage: Schema.mutableKey(Schema.NullOr(CodexTokenUsage)), + malformedRecords: Schema.mutableKey(TokenCount), sawSessionMeta: Schema.mutableKey(Schema.Boolean), + modelProvider: Schema.mutableKey(Schema.String), + turnId: Schema.mutableKey(Schema.String), + sawThreadSettings: Schema.mutableKey(Schema.Boolean), suppressingForkCopies: Schema.mutableKey(Schema.Boolean), forkCopyAnchorMs: Schema.mutableKey(Schema.Finite), }); @@ -37,7 +87,12 @@ export function initialCodexScanState(): CodexScanState { speed: "standard", sessionId: "", lastUsageSignature: null, + lastCumulativeUsage: null, + malformedRecords: 0, sawSessionMeta: false, + modelProvider: "", + turnId: "", + sawThreadSettings: false, suppressingForkCopies: false, forkCopyAnchorMs: 0, }; @@ -63,8 +118,8 @@ function isForkedSessionMeta(payload: Record): boolean { } /** - * Deltas come from `last_token_usage`. Summing those across a session reconciles with the session's - * final `total_token_usage`, provided consecutive duplicate events are dropped, which this does. + * Cumulative counters validate request deltas. Older events without cumulative counters use + * normalized consecutive-request deduplication. */ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | null { let parsed: unknown; @@ -93,6 +148,9 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | state.sawSessionMeta = true; const id = payloadRecord["id"] ?? payloadRecord["session_id"]; if (typeof id === "string") state.sessionId = id; + if (typeof payloadRecord["model_provider"] === "string") { + state.modelProvider = payloadRecord["model_provider"]; + } const metaTimestampMs = parseTimestampMs(record["timestamp"]); if (metaTimestampMs !== null && isForkedSessionMeta(payloadRecord)) { state.suppressingForkCopies = true; @@ -103,10 +161,18 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | if (record["type"] === "turn_context") { if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"]; + // Model carry is useful for usage, but history recovery needs this context's own identity. + state.turnId = + typeof payloadRecord["turn_id"] === "string" && + typeof payloadRecord["model"] === "string" && + payloadRecord["model"].length > 0 + ? payloadRecord["turn_id"] + : ""; return null; } if (payloadType === "thread_settings_applied") { + state.sawThreadSettings = true; const settings = payloadRecord["thread_settings"]; if (typeof settings === "object" && settings !== null) { state.speed = codexSpeed((settings as Record)["service_tier"]); @@ -118,45 +184,97 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | const info = payloadRecord["info"]; if (typeof info !== "object" || info === null) return null; - const last = (info as Record)["last_token_usage"]; - if (typeof last !== "object" || last === null) return null; - const lastRecord = last as Record; - - // Only an event that is otherwise eligible may consume the duplicate - // signature. A token_count arriving before its turn_context (no model yet) - // must not poison it, or the re-emitted copy after the model is known would - // be skipped as a duplicate and those tokens never counted. + const infoRecord = info as Record; + + // Codex may re-emit these counters after the model or timestamp becomes available. const timestampMs = parseTimestampMs(record["timestamp"]); - if (timestampMs === null) return null; - if (state.model.length === 0) return null; + if (timestampMs === null || state.model.length === 0) return null; - // Codex re-emits an unchanged token_count on some stream boundaries. Summing - // those would double count, so identical consecutive payloads are skipped. - const signature = JSON.stringify(lastRecord); - if (signature === state.lastUsageSignature) return null; - state.lastUsageSignature = signature; + const last = readCodexTokenUsage(infoRecord["last_token_usage"]); + const rawCumulative = infoRecord["total_token_usage"]; + const hasCumulative = rawCumulative !== undefined && rawCumulative !== null; + const cumulative = hasCumulative ? readCodexTokenUsage(rawCumulative) : null; if (state.suppressingForkCopies) { if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) { state.forkCopyAnchorMs = timestampMs; + // Copied history is already billed in the parent, but establishes the child's baseline. + const signature = last === null ? null : JSON.stringify(last); + if (cumulative !== null) state.lastCumulativeUsage = cumulative; + else if (!hasCumulative && last !== null && signature !== state.lastUsageSignature) { + state.lastCumulativeUsage = advanceCodexUsage(state.lastCumulativeUsage, last); + } + state.lastUsageSignature = signature; return null; } state.suppressingForkCopies = false; + state.lastUsageSignature = null; + } + + const previous = state.lastCumulativeUsage; + // A valid snapshot remains the next baseline even when its paired request is malformed. + if (cumulative !== null) state.lastCumulativeUsage = cumulative; + if (last === null || (hasCumulative && cumulative === null)) { + state.malformedRecords += 1; + return null; + } + + // Zero-component counters report context size, not a request: fill_to_context_window, or the + // post-compaction marker whose total is the new context size (its cumulative may reset to zero). + if (last.input_tokens + last.output_tokens === 0) { + state.lastUsageSignature = null; + return null; + } + + if ( + cumulative !== null && + previous !== null && + CODEX_USAGE_FIELDS.every((key) => cumulative[key] === previous[key]) + ) { + return null; + } + + if (last.total_tokens !== last.input_tokens + last.output_tokens) { + state.malformedRecords += 1; + return null; + } + + const signature = JSON.stringify(last); + if (cumulative !== null) { + // A session resumed in a new Codex process restarts its cumulative counters at this request. + const restarted = CODEX_USAGE_FIELDS.every((key) => cumulative[key] === last[key]); + if ( + !restarted && + !CODEX_USAGE_FIELDS.every((key) => cumulative[key] - (previous?.[key] ?? 0) === last[key]) + ) { + state.malformedRecords += 1; + return null; + } + } else { + if (signature === state.lastUsageSignature) return null; + // Advance expected totals for billed legacy requests so a cumulative re-emission is stale. + const advanced = advanceCodexUsage(previous, last); + if (advanced === null) { + state.malformedRecords += 1; + return null; + } + state.lastCumulativeUsage = advanced; } + state.lastUsageSignature = signature; - const inputTokens = tokenCount(lastRecord["input_tokens"]); - const cachedInputTokens = tokenCount(lastRecord["cached_input_tokens"]); - const cacheCreationTokens = tokenCount(lastRecord["cache_write_input_tokens"]); - const outputTokens = tokenCount(lastRecord["output_tokens"]); + const inputTokens = last.input_tokens; + const cachedInputTokens = last.cached_input_tokens; + const cacheCreationTokens = last.cache_write_input_tokens; + const outputTokens = last.output_tokens; const totals: UsageTokenTotals = { // Codex reports `input_tokens` inclusive of the cached portion. - uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens), + uncachedInputTokens: inputTokens - cachedInputTokens - cacheCreationTokens, cachedInputTokens, cacheCreationTokens, outputTokens, // Reported inside output_tokens, surfaced separately for the token mix. - reasoningTokens: Math.min(outputTokens, tokenCount(lastRecord["reasoning_output_tokens"])), + reasoningTokens: last.reasoning_output_tokens, }; if (totalTokens(totals) === 0) return null; @@ -169,6 +287,12 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | totals, reportedCostUsd: null, speed: state.speed, + ...(!state.sawThreadSettings && + state.modelProvider === "openai" && + state.sessionId.length > 0 && + state.turnId.length > 0 + ? { unresolvedServiceTierTurnId: state.turnId } + : {}), dedupeKey: null, }; } @@ -194,11 +318,13 @@ export const codexUsageFormat: TranscriptUsageFormat = { type: true, id: true, session_id: true, + model_provider: true, + turn_id: true, model: true, thread_settings: { service_tier: true }, forked_from_id: true, source: { subagent: { thread_spawn: { parent_thread_id: true } } }, - info: { last_token_usage: true }, + info: { last_token_usage: true, total_token_usage: true }, }, }, // `turn_context`, `thread_settings_applied` and `session_meta` lines hold no @@ -207,14 +333,45 @@ export const codexUsageFormat: TranscriptUsageFormat = { parseLine: (line, state) => orEmpty(parseCodexLine(line, state)), parseProjected: (projected, state) => orEmpty(parseCodexRecord(projected, state)), state: { initial: initialCodexScanState, schema: CodexScanState }, + readMalformedRecords: (state) => state.malformedRecords, // A rollout that moved after it was read leaves a copy in both places. sharedSessionsAcrossFiles: true, }; -export const codexUsageReader: ProviderUsageReader = { +export const codexUsageReader: ProviderUsageReader< + CodexSettings, + Path.Path | CodexUsageHistory.CodexUsageHistory +> = { kind: "transcripts", provider: "codex", format: codexUsageFormat, + resolveServiceTiers: Effect.fn("codexUsageReader.resolveServiceTiers")(function* (records) { + const history = yield* CodexUsageHistory.CodexUsageHistory; + const requests = new Map(); + for (const record of records) { + if (record.unresolvedServiceTierTurnId === undefined) continue; + requests.set( + { + sessionId: record.sessionId, + nativeTurnId: record.unresolvedServiceTierTurnId, + model: record.model, + }, + record, + ); + } + const resolved = yield* history.resolve([...requests.keys()]).pipe( + Effect.catchTags({ + CodexUsageHistoryReadError: () => + Effect.succeed(new Map()), + }), + ); + const speeds = new Map(); + for (const [request, speed] of resolved) { + const record = requests.get(request); + if (record !== undefined) speeds.set(record, speed); + } + return speeds; + }), directories: Effect.fn("codexUsageReader.directories")(function* ({ config, environment }) { if (config === undefined) return []; const path = yield* Path.Path; diff --git a/apps/server/src/provider/EventNdjsonLogger.test.ts b/apps/server/src/provider/EventNdjsonLogger.test.ts index e1af016095ee..c932ac2fa5e5 100644 --- a/apps/server/src/provider/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/EventNdjsonLogger.test.ts @@ -7,7 +7,6 @@ import { ThreadId } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { RotatingFileSinkError } from "@t3tools/shared/logging"; -import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as FileSystem from "effect/FileSystem"; @@ -16,7 +15,6 @@ import * as Logger from "effect/Logger"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts"; import { makeEventNdjsonLogger, makeEventNdjsonLogStore, @@ -90,52 +88,6 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { }).pipe(Effect.provide(Logger.layer([logCapture], { mergeWithExisting: false }))); }); - it.effect("writes effect-style lines to thread-scoped files", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "provider-native.ndjson"); - - try { - const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); - assert.notEqual(logger, undefined); - if (!logger) { - return; - } - - yield* logger.write( - { threadId: "provider-thread-1", id: "evt-1" }, - ThreadId.make("thread-1"), - ); - yield* logger.write( - { type: "turn.completed", threadId: "provider-thread-2", id: "evt-2" }, - ThreadId.make("thread-2"), - ); - yield* logger.close(); - - const threadOnePath = ownedLogPath(basePath, "thread-1"); - const threadTwoPath = ownedLogPath(basePath, "thread-2"); - assert.equal(NodeFS.existsSync(threadOnePath), true); - assert.equal(NodeFS.existsSync(threadTwoPath), true); - - const first = parseLogLine(NodeFS.readFileSync(threadOnePath, "utf8").trim()); - const second = parseLogLine(NodeFS.readFileSync(threadTwoPath, "utf8").trim()); - - assert.equal(Number.isNaN(Date.parse(first.observedAt)), false); - assert.equal(first.stream, "NTIVE"); - assert.equal(first.payload, '{"threadId":"provider-thread-1","id":"evt-1"}'); - - assert.equal(Number.isNaN(Date.parse(second.observedAt)), false); - assert.equal(second.stream, "NTIVE"); - assert.equal( - second.payload, - '{"type":"turn.completed","threadId":"provider-thread-2","id":"evt-2"}', - ); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - it.effect( "falls back to a global segment when orchestration thread id is missing or invalid", () => @@ -246,28 +198,6 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { }), ); - it.effect("flushes an active batch without a permanent polling loop", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const threadPath = ownedLogPath(basePath, "thread-batched"); - - try { - const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); - yield* store - .logger("native") - .write({ id: "batched-event" }, ThreadId.make("thread-batched")); - - assert.equal(NodeFS.existsSync(threadPath), false); - yield* TestClock.adjust(1_000); - yield* store.close(); - assert.equal(NodeFS.existsSync(threadPath), true); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - it.effect("does not strand a later batch after an interrupted write", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); @@ -295,200 +225,6 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { }), ); - it.effect("drops transient provider events before serialization", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - - try { - const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); - const canonical = store.logger("canonical"); - const native = store.logger("native"); - const threadId = ThreadId.make("thread-filtered"); - const circularDelta: Record = { type: "content.delta" }; - circularDelta["self"] = circularDelta; - - yield* canonical.write(circularDelta, threadId); - yield* canonical.write({ type: "item.completed", id: "final" }, threadId); - yield* native.write({ type: "content.delta", id: "native-delta" }, threadId); - yield* native.write( - { method: "item/agentMessage/delta", payload: circularDelta }, - threadId, - ); - yield* native.write( - { method: "thread/realtime/outputAudio/delta", payload: circularDelta }, - threadId, - ); - yield* native.write( - { method: "thread/realtime/transcript/delta", payload: circularDelta }, - threadId, - ); - yield* native.write({ method: "turn/diff/updated", payload: circularDelta }, threadId); - yield* native.write( - { - event: { - direction: "incoming", - stage: "decoded", - payload: { - method: "turn/diff/updated", - get params() { - throw new Error("unused diff snapshots must not be traversed"); - }, - }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - method: "claude/stream_event/content_block_delta/text_delta", - payload: circularDelta, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - method: "session/update", - payload: { update: { sessionUpdate: "agent_message_chunk" } }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - direction: "incoming", - stage: "decoded", - payload: { method: "item/commandExecution/outputDelta", params: circularDelta }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - direction: "incoming", - stage: "decoded", - payload: { - type: "stream_event", - event: { type: "content_block_delta", delta: circularDelta }, - }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - direction: "incoming", - stage: "raw", - get payload() { - throw new Error("raw frames must not be inspected"); - }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - type: "message.part.updated", - payload: { properties: { part: { type: "text" } } }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - type: "message.part.updated", - payload: { - properties: { - part: { type: "tool", state: { status: "running", output: "progress" } }, - }, - }, - }, - }, - threadId, - ); - yield* native.write({ type: "turn.completed", id: "native-final" }, threadId); - yield* store.close(); - - const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8") - .trim() - .split("\n") - .map(parseLogLine); - - assert.deepEqual( - lines.map(({ stream, payload }) => ({ stream, payload })), - [ - { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' }, - { stream: "NTIVE", payload: '{"type":"turn.completed","id":"native-final"}' }, - ], - ); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect( - "drops Claude thinking-token counters while keeping system and terminal usage records", - () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const threadId = ThreadId.make("claude-thinking-tokens"); - const counter = { - direction: "incoming", - stage: "decoded", - payload: { - type: "system", - subtype: "thinking_tokens", - estimated_tokens: 50, - estimated_tokens_delta: 50, - }, - }; - const retained = [ - { type: "system", subtype: "init", session_id: "claude-session" }, - { - type: "stream_event", - event: { type: "message_delta", usage: { output_tokens: 10 } }, - }, - { - type: "result", - subtype: "success", - usage: { output_tokens: 10, output_tokens_details: { thinking_tokens: 5 } }, - }, - ].map((payload) => ({ event: { direction: "incoming", stage: "decoded", payload } })); - - try { - const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); - const native = store.logger("native"); - yield* native.write(counter, threadId); - yield* native.write({ provider: "claudeAgent", event: counter }, threadId); - for (const event of retained) { - yield* native.write(event, threadId); - } - yield* store.close(); - - const records = NodeFS.readFileSync( - ownedLogPath(basePath, "claude-thinking-tokens"), - "utf8", - ) - .trim() - .split("\n") - .map((line) => decodeUnknownJson(parseLogLine(line).payload)); - assert.deepEqual(records, retained); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - it.effect("summarizes large histories without reading their items", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); @@ -602,84 +338,6 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { }), ); - it.effect("keeps OpenCode tool input, final output, and errors in native logs", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const threadId = ThreadId.make("thread-tool-lifecycle"); - - try { - const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); - const native = store.logger("native"); - const input = { command: "run-build" }; - const states = [ - { status: "pending", input }, - { status: "completed", input, output: "Build complete", time: { start: 1, end: 2 } }, - { status: "error", input, error: "Build failed", time: { start: 3, end: 4 } }, - { status: "unknown", input }, - null, - ]; - const events = states.map((state) => ({ - event: { - type: "message.part.updated", - payload: { properties: { part: { type: "tool", state } } }, - }, - })); - for (const event of events) { - yield* native.write(event, threadId); - } - yield* store.close(); - - const payloads = NodeFS.readFileSync( - ownedLogPath(basePath, "thread-tool-lifecycle"), - "utf8", - ) - .trim() - .split("\n") - .map((line) => parseLogLine(line).payload); - assert.deepEqual( - payloads, - events.map((event) => encodeUnknownJson(event)), - ); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("contains hostile event accessors inside guarded serialization", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - - try { - const logger = yield* makeEventNdjsonLogger(basePath, { - stream: "canonical", - batchWindowMs: 0, - }); - assert.exists(logger); - if (!logger) return; - const hostile = new Proxy( - { id: "hostile" }, - { - get(_target, property) { - if (property === "type") throw new Error("blocked"); - return undefined; - }, - }, - ); - - yield* logger.write(hostile, ThreadId.make("thread-hostile")); - yield* logger.close(); - - const contents = NodeFS.readFileSync(ownedLogPath(basePath, "thread-hostile"), "utf8"); - assert.notInclude(contents, "blocked"); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - it.effect("serializes concurrent first writes for the same segment", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); @@ -773,79 +431,6 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { }), ); - it.effect("enforces aggregate age and byte retention on startup", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const expiredPath = ownedLogPath(basePath, "expired"); - const oldPath = ownedLogPath(basePath, "old"); - const newPath = ownedLogPath(basePath, "new"); - const unrelatedLogPath = NodePath.join(tempDir, "unrelated.log"); - const legacyLogPath = NodePath.join(tempDir, "legacy-thread.log"); - const ignoredPath = NodePath.join(tempDir, "ignored.txt"); - - try { - yield* TestClock.setTime(1_800_000_000_000); - const now = yield* Clock.currentTimeMillis; - for (const filePath of [expiredPath, oldPath, newPath, unrelatedLogPath, ignoredPath]) { - NodeFS.writeFileSync(filePath, "x".repeat(40)); - } - NodeFS.writeFileSync( - legacyLogPath, - "[2026-01-01T00:00:00.000Z] CANON: legacy provider event\n", - ); - NodeFS.utimesSync(expiredPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000); - NodeFS.utimesSync(legacyLogPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000); - NodeFS.utimesSync(oldPath, (now - 5_000) / 1_000, (now - 5_000) / 1_000); - NodeFS.utimesSync(newPath, now / 1_000, now / 1_000); - - const store = yield* makeEventNdjsonLogStore(basePath, { - maxAgeMs: 10_000, - maxTotalBytes: 60, - }); - yield* store.close(); - - assert.equal(NodeFS.existsSync(expiredPath), false); - assert.equal(NodeFS.existsSync(legacyLogPath), false); - assert.equal(NodeFS.existsSync(oldPath), false); - assert.equal(NodeFS.existsSync(newPath), true); - assert.equal(NodeFS.existsSync(unrelatedLogPath), true); - assert.equal(NodeFS.existsSync(ignoredPath), true); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("does not prune an active thread sink during an unrelated flush", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const activePath = ownedLogPath(basePath, "active"); - - try { - yield* TestClock.setTime(1_800_000_000_000); - const store = yield* makeEventNdjsonLogStore(basePath, { - batchWindowMs: 0, - maxAgeMs: 1, - retentionCheckIntervalMs: 1, - }); - const logger = store.logger("native"); - - yield* logger.write({ id: "active-before-retention" }, ThreadId.make("active")); - assert.equal(NodeFS.existsSync(activePath), true); - - yield* TestClock.adjust("2 millis"); - yield* logger.write({ id: "retention-trigger" }, ThreadId.make("other")); - - assert.equal(NodeFS.existsSync(activePath), true); - yield* store.close(); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - it.effect("attributes batches that were written before a later chunk fails", () => Effect.gen(function* () { const records: ReadonlyArray = [ @@ -939,36 +524,4 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { } }), ); - - it.effect("reports logical provider log writes to resource attribution", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "provider-native.ndjson"); - - try { - const attribution = yield* ResourceAttribution.make(); - const logger = yield* makeEventNdjsonLogger(basePath, { - stream: "native", - batchWindowMs: 0, - attribution, - }); - assert.notEqual(logger, undefined); - if (!logger) { - return; - } - - yield* logger.write({ id: "attributed-event" }, ThreadId.make("thread-attribution")); - yield* logger.close(); - - const snapshot = yield* attribution.snapshot; - assert.equal(snapshot.entries.length, 1); - assert.equal(snapshot.entries[0]?.component, "provider-event-log"); - assert.equal(snapshot.entries[0]?.operation, "native.append"); - assert.equal(snapshot.entries[0]?.count, 1); - assert.isAbove(snapshot.entries[0]?.logicalWriteBytes ?? 0, 0); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); }); diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 6f532f00bbb5..373d9773280a 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -12,8 +12,6 @@ import { GrokSettings } from "@t3tools/provider-grok/settings"; import { buildGrokModelCapabilities, - buildGrokModelsFromSessionModelState, - buildInitialGrokProviderSnapshot, checkGrokProviderStatus, grokSlashCommandsFromInitialize, parseGrokModelsCliOutput, @@ -53,10 +51,6 @@ describe("parseGrokModelsCliOutput", () => { it("detects a logged-out CLI even though it exits 0", () => { expect(parseGrokModelsCliOutput(LOGGED_OUT_MODELS_OUTPUT).authenticated).toBe(false); }); - - it("returns unknown auth for unrecognized output", () => { - expect(parseGrokModelsCliOutput("grok 9.9.9\n").authenticated).toBeNull(); - }); }); describe("grokSlashCommandsFromInitialize", () => { @@ -110,101 +104,9 @@ describe("grokSlashCommandsFromInitialize", () => { expect(commands.map((command) => command.name)).toEqual(["compact", "session-info"]); expect(commands[1]?.description).toBe("Updated session details"); }); - - it("keeps compact available for older agents without command metadata", () => { - for (const _meta of [undefined, {}, { availableCommands: "invalid" }]) { - expect( - grokSlashCommandsFromInitialize({ protocolVersion: 1, ...(_meta ? { _meta } : {}) }).map( - (command) => command.name, - ), - ).toEqual(["compact"]); - } - }); -}); - -describe("buildGrokModelsFromSessionModelState", () => { - it("marks the agent's current model as default and keeps reasoning options", () => { - const models = buildGrokModelsFromSessionModelState({ - currentModelId: "grok-4.6", - availableModels: [ - { - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { - supportsReasoningEffort: true, - reasoningEffort: "high", - reasoningEfforts: [{ value: "high", label: "High", default: true }], - }, - }, - { modelId: "grok-4.5", name: "Grok 4.5" }, - ], - }); - expect(models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ - ["grok-4.6", true], - ["grok-4.5", false], - ]); - expect(models[0]?.capabilities?.optionDescriptors).toHaveLength(1); - }); }); describe("buildGrokModelCapabilities", () => { - it("preserves ACP-provided reasoning labels and the active default", () => { - const capabilities = buildGrokModelCapabilities({ - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { - supportsReasoningEffort: true, - reasoningEffort: "xhigh", - reasoningEfforts: [ - { value: "xhigh", label: "Extra High Effort", default: true }, - { value: "high", label: "High Effort", default: true }, - { value: "medium", label: "Medium Effort" }, - { value: "low", label: "Low Effort" }, - ], - }, - }); - - expect(capabilities.optionDescriptors).toEqual([ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - currentValue: "xhigh", - options: [ - { id: "xhigh", label: "Extra High Effort", isDefault: true }, - { id: "high", label: "High Effort" }, - { id: "medium", label: "Medium Effort" }, - { id: "low", label: "Low Effort" }, - ], - }, - ]); - }); - - it("uses raw ACP values when option labels are omitted", () => { - const capabilities = buildGrokModelCapabilities({ - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { - supportsReasoningEffort: true, - reasoningEffort: "xhigh", - reasoningEfforts: [{ value: "xhigh" }, { value: "medium" }], - }, - }); - - expect(capabilities.optionDescriptors).toEqual([ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - currentValue: "xhigh", - options: [ - { id: "xhigh", label: "xhigh" }, - { id: "medium", label: "medium" }, - ], - }, - ]); - }); - it("keeps ACP current effort separate from its collapsed advertised default", () => { const capabilities = buildGrokModelCapabilities({ modelId: "grok-4.6", @@ -273,19 +175,6 @@ describe("buildGrokModelCapabilities", () => { ]); }); - it("accepts an advertised ACP menu when the support flag is omitted", () => { - const capabilities = buildGrokModelCapabilities({ - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { - reasoningEffort: "high", - reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], - }, - }); - - expect(capabilities.optionDescriptors).toHaveLength(1); - }); - it("honors an explicit ACP opt-out even when a menu is present", () => { const capabilities = buildGrokModelCapabilities({ modelId: "grok-4.6", @@ -298,77 +187,9 @@ describe("buildGrokModelCapabilities", () => { expect(capabilities.optionDescriptors).toEqual([]); }); - - it("does not synthesize a reasoning menu when ACP omits it", () => { - expect( - buildGrokModelCapabilities({ - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { supportsReasoningEffort: true, reasoningEffort: "xhigh" }, - }).optionDescriptors, - ).toEqual([]); - }); - - it("keeps non-reasoning Grok models free of reasoning controls", () => { - expect( - buildGrokModelCapabilities({ modelId: "grok-build", name: "Grok Build" }).optionDescriptors, - ).toEqual([]); - }); -}); - -describe("buildInitialGrokProviderSnapshot", () => { - it.effect("returns a disabled snapshot when settings.enabled is false", () => - Effect.gen(function* () { - const snapshot = yield* buildInitialGrokProviderSnapshot( - decodeGrokSettings({ enabled: false }), - ); - expect(snapshot.enabled).toBe(false); - expect(snapshot.status).toBe("disabled"); - expect(snapshot.installed).toBe(false); - expect(snapshot.message).toContain("disabled"); - }), - ); - - it.effect("returns a disabled snapshot by default — Grok is opt-in", () => - Effect.gen(function* () { - const snapshot = yield* buildInitialGrokProviderSnapshot(decodeGrokSettings({})); - expect(snapshot.enabled).toBe(false); - expect(snapshot.status).toBe("disabled"); - }), - ); - - it.effect("returns a pending snapshot when enabled", () => - Effect.gen(function* () { - const snapshot = yield* buildInitialGrokProviderSnapshot( - decodeGrokSettings({ enabled: true }), - ); - expect(snapshot.enabled).toBe(true); - expect(snapshot.installed).toBe(true); - expect(snapshot.status).toBe("warning"); - expect(snapshot.version).toBeNull(); - expect(snapshot.message).toContain("Checking Grok"); - expect(snapshot.requiresNewThreadForModelChange).toBeUndefined(); - expect(snapshot.supportsConversationRollback).toBe(false); - }), - ); }); it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { - it.effect("reports the binary as missing when the binary path does not resolve", () => - Effect.gen(function* () { - const snapshot = yield* checkGrokProviderStatus( - decodeGrokSettings({ - enabled: true, - binaryPath: "/definitely/not/installed/grok-binary", - }), - ); - expect(snapshot.enabled).toBe(true); - expect(snapshot.installed).toBe(false); - expect(snapshot.status).toBe("error"); - expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); - }), - ); - it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => Effect.gen(function* () { const secretStderr = "broken grok install: secret-token-value"; @@ -402,8 +223,9 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { // A stand-in for the Grok CLI: `--version` and `models` print canned text, // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata. + // A list of `models` outputs is served in call order, repeating the last one. const writeFakeGrokCli = (input: { - readonly modelsOutput: string; + readonly modelsOutput: string | ReadonlyArray; readonly acp: boolean; readonly updateLogPath?: string; }) => @@ -428,7 +250,12 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { " process.exit(0);", "}", 'if (process.argv[2] === "models") {', - ` process.stdout.write(${JSON.stringify(input.modelsOutput)});`, + ' const fs = await import("node:fs");', + ` const callsPath = ${JSON.stringify(NodePath.join(dir, "models-calls"))};`, + ' const calls = fs.existsSync(callsPath) ? fs.readFileSync(callsPath, "utf8").length : 0;', + ' fs.appendFileSync(callsPath, ".");', + ` const outputs = ${JSON.stringify([input.modelsOutput].flat())};`, + " process.stdout.write(outputs[Math.min(calls, outputs.length - 1)]);", " process.exit(0);", "}", 'if (process.argv[2] !== "agent") process.exit(1);', @@ -476,12 +303,14 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); - it.effect("reports unauthenticated from `grok models` without starting a session", () => + // An expired access token prints the logged-out banner, then the CLI refreshes it in the + // same run, so the next `grok models` reports the login (#14285). + it.effect("reports authenticated when `grok models` logs in after refreshing a token", () => Effect.gen(function* () { const snapshot = yield* Effect.scoped( Effect.gen(function* () { const grokPath = yield* writeFakeGrokCli({ - modelsOutput: LOGGED_OUT_MODELS_OUTPUT, + modelsOutput: [LOGGED_OUT_MODELS_OUTPUT, LOGGED_IN_MODELS_OUTPUT], acp: true, }); return yield* checkGrokProviderStatus( @@ -491,10 +320,8 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); - expect(snapshot.status).toBe("error"); - expect(snapshot.auth.status).toBe("unauthenticated"); - expect(snapshot.message).toContain("grok login"); - expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth.status).toBe("authenticated"); }), ); @@ -552,29 +379,63 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { describe("Grok usage limits", () => { const checkedAt = "2026-09-16T00:00:00.000Z"; - - it("uses the reported subscription percentage and weekly reset", () => { - const limits = grokUsageResponseToLimits( - { - config: { - creditUsagePercent: 100, - currentPeriod: { - type: "USAGE_PERIOD_TYPE_WEEKLY", - end: "2026-09-18T03:10:30.159171+00:00", - }, + const weeklyPeriod = { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-09-11T03:10:30.159171+00:00", + end: "2026-09-18T03:10:30.159171+00:00", + }; + + it("shows zero usage for complete weekly and monthly periods with no percentage", () => { + for (const [currentPeriod, kind, label, resetsAt] of [ + [weeklyPeriod, "weekly", "Weekly", "2026-09-18T03:10:30.159Z"], + [ + { + type: "USAGE_PERIOD_TYPE_MONTHLY", + start: "2026-09-01T00:00:00.000Z", + end: "2026-10-01T00:00:00.000Z", }, - }, - checkedAt, - ); - expect(limits.windows).toEqual([ - { - id: "subscription", - kind: "weekly", - label: "Weekly", - usedPercent: 100, - resetsAt: "2026-09-18T03:10:30.159Z", - }, - ]); + "monthly", + "Monthly", + "2026-10-01T00:00:00.000Z", + ], + ] as const) { + const response = { config: { currentPeriod } }; + expect + .soft(grokUsageResponseToLimits(response, checkedAt).windows) + .toEqual([{ id: "subscription", kind, label, usedPercent: 0, resetsAt }]); + } + }); + + it("keeps incomplete, invalid, unordered, and unknown periods unreported", () => { + for (const currentPeriod of [ + undefined, + { ...weeklyPeriod, type: undefined }, + { ...weeklyPeriod, type: "USAGE_PERIOD_TYPE_DAILY" }, + { ...weeklyPeriod, start: undefined }, + { ...weeklyPeriod, end: undefined }, + { ...weeklyPeriod, start: "invalid" }, + { ...weeklyPeriod, end: "invalid" }, + { ...weeklyPeriod, start: weeklyPeriod.end }, + { ...weeklyPeriod, start: weeklyPeriod.end, end: weeklyPeriod.start }, + ]) { + const response = { config: { currentPeriod } }; + expect + .soft(grokUsageResponseToLimits(response, checkedAt).windows, JSON.stringify(response)) + .toEqual([]); + } + }); + + // regression: #17061 show zero-use Grok billing periods + it("rejects impossible calendar dates at either period boundary", () => { + for (const [start, end] of [ + ["2026-02-30T00:00:00.000Z", "2026-03-09T00:00:00.000Z"], + ["2026-02-23T00:00:00.000Z", "2026-02-30T00:00:00.000Z"], + ]) { + const response = { config: { currentPeriod: { ...weeklyPeriod, start, end } } }; + expect + .soft(grokUsageResponseToLimits(response, checkedAt).windows, JSON.stringify(response)) + .toEqual([]); + } }); it("does not invent allowance or reset dates when billing omits them", () => { @@ -742,38 +603,6 @@ it.layer(NodeServices.layer)("readGrokAccount", (it) => { }).pipe(Effect.scoped), ); - it.effect( - "does not request subscription limits for custom account or endpoint configuration", - () => - Effect.gen(function* () { - for (const config of [ - '[auth]\nprovider_command = "custom-auth"', - '[grok_com_config]\nissuer = "https://custom.example"', - 'endpoints.proxy = "https://custom.example"', - ]) { - const { usageLimits: limits } = yield* readGrokAccount({ - GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-token"}}', - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: (path) => { - expect(path.endsWith("config.toml")).toBe(true); - return Effect.succeed(config); - }, - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not request another account's quota")), - ), - ); - expect(limits.windows).toEqual([]); - expect(limits.unavailable?.reason).toBe("unsupported"); - } - }), - ); - it.effect("sanitizes HTTP failures and malformed billing responses, keeping the account", () => Effect.gen(function* () { for (const response of [ diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 635e5aa78911..560b702db3aa 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -13,59 +13,6 @@ import * as ServerSettings from "../serverSettings.ts"; import * as ModelManifest from "./ModelManifest.ts"; describe("resolveProviderCatalog", () => { - it("resolves generic model presentation through a reusable profile", () => { - const manifest: ModelManifest.ModelManifestData = { - version: 1, - currentModels: {}, - providers: { - synthetic: { - defaults: { chat: "model-next" }, - profiles: { - standard: { - capabilities: { - optionDescriptors: [ - { - id: "mode", - label: "Mode", - type: "select", - options: [{ id: "fast", label: "Fast", isDefault: true }], - }, - ], - }, - adapter: { opaque: true }, - }, - }, - models: [ - { - slug: "model-next", - name: "Model Next", - aliases: ["next"], - status: "current", - badge: "new", - profile: "standard", - }, - ], - }, - }, - }; - - const catalog = ModelManifest.resolveProviderCatalog( - manifest, - ProviderDriverKind.make("synthetic"), - ); - assert.deepStrictEqual(catalog?.models[0], { - slug: "model-next", - name: "Model Next", - aliases: ["next"], - badge: "new", - status: "current", - capabilities: manifest.providers!.synthetic!.profiles.standard!.capabilities!, - adapter: undefined, - profileAdapter: { opaque: true }, - }); - assert.strictEqual(catalog?.defaultChatModel, "model-next"); - }); - it("rejects invalid catalog references", () => { const invalidCatalog = (input: { readonly models: NonNullable[string]["models"]; @@ -236,84 +183,6 @@ const layerService = (input: { ); describe("ModelManifest service", () => { - it.live("explicit refresh bypasses fresh memory and disk caches", () => { - let fetchCount = 0; - const updated: ModelManifest.ModelManifestData = { - ...REMOTE_MANIFEST, - currentModels: { codex: ["gpt-reloaded"] }, - }; - return Effect.gen(function* () { - const service = yield* ModelManifest.make; - assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); - assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); - assert.strictEqual(fetchCount, 1); - - const rebooted = yield* ModelManifest.make; - assert.deepStrictEqual(yield* rebooted.refresh, REMOTE_MANIFEST); - assert.strictEqual(fetchCount, 1); - assert.deepStrictEqual(yield* rebooted.forceRefresh, updated); - assert.strictEqual(fetchCount, 2); - assert.deepStrictEqual(yield* rebooted.current, updated); - assert.deepStrictEqual(yield* (yield* ModelManifest.make).current, updated); - }).pipe( - Effect.scoped, - Effect.provide( - layerService({ - prefix: "model-manifest-force-refresh-test", - response: () => Response.json(fetchCount++ === 0 ? REMOTE_MANIFEST : updated), - }), - ), - ); - }); - - it.live("explicit refresh retries immediately after failure and preserves last-good data", () => { - let fetchCount = 0; - return Effect.gen(function* () { - const service = yield* ModelManifest.make; - assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); - assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); - assert.deepStrictEqual(yield* service.current, REMOTE_MANIFEST); - assert.deepStrictEqual(yield* (yield* ModelManifest.make).current, REMOTE_MANIFEST); - assert.strictEqual(fetchCount, 2); - assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); - assert.strictEqual(fetchCount, 3); - }).pipe( - Effect.scoped, - Effect.provide( - layerService({ - prefix: "model-manifest-force-retry-test", - response: () => - fetchCount++ === 1 - ? new Response(null, { status: 503 }) - : Response.json(REMOTE_MANIFEST), - }), - ), - ); - }); - - it.live("explicit refresh bypasses the retry delay after an initial failure", () => { - let fetchCount = 0; - return Effect.gen(function* () { - const service = yield* ModelManifest.make; - assert.deepStrictEqual(yield* service.refresh, ModelManifest.BUNDLED_MODEL_MANIFEST); - assert.deepStrictEqual(yield* service.refresh, ModelManifest.BUNDLED_MODEL_MANIFEST); - assert.strictEqual(fetchCount, 1); - assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); - assert.strictEqual(fetchCount, 2); - }).pipe( - Effect.scoped, - Effect.provide( - layerService({ - prefix: "model-manifest-force-initial-retry-test", - response: () => - fetchCount++ === 0 - ? new Response(null, { status: 503 }) - : Response.json(REMOTE_MANIFEST), - }), - ), - ); - }); - it.live("prefers a fetched manifest over the bundle and caches it to disk", () => Effect.gen(function* () { const service = yield* ModelManifest.make; @@ -357,21 +226,6 @@ describe("ModelManifest service", () => { ); }); - it.live("keeps the bundled manifest when the remote payload is malformed", () => - Effect.gen(function* () { - const service = yield* ModelManifest.make; - assert.deepStrictEqual(yield* service.refresh, ModelManifest.BUNDLED_MODEL_MANIFEST); - }).pipe( - Effect.scoped, - Effect.provide( - layerService({ - prefix: "model-manifest-malformed-test", - response: () => Response.json({ version: 999, nonsense: true }), - }), - ), - ), - ); - it.effect("preserves the last-good remote cache when later payloads are invalid", () => { let responseIndex = 0; const responses = [REMOTE_CLAUDE_MANIFEST, ...INVALID_REMOTE_MANIFESTS]; @@ -461,43 +315,3 @@ describe("ModelManifest service", () => { ), ); }); - -it.effect("caches valid compatibility policies and keeps them after a malformed refresh", () => { - const remote: ModelManifest.ModelManifestData = { - ...REMOTE_MANIFEST, - compatibility: [ - { - driver: "codex", - t3CodeRange: ">=0.0.42", - recommendedVersion: "2.0.0", - ranges: [{ range: "=2.0.0", status: "supported" }], - }, - ], - }; - let invalid = false; - return Effect.gen(function* () { - const service = yield* ModelManifest.make; - assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); - invalid = true; - yield* TestClock.adjust("1 hour"); - assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); - const rebooted = yield* ModelManifest.make; - assert.deepStrictEqual((yield* rebooted.current).compatibility, remote.compatibility); - }).pipe( - Effect.scoped, - Effect.provide( - layerService({ - prefix: "model-manifest-compatibility-test", - response: () => - Response.json( - invalid - ? { - ...remote, - compatibility: [{ ...remote.compatibility![0], recommendedVersion: "3.0.0" }], - } - : remote, - ), - }), - ), - ); -}); diff --git a/apps/server/src/provider/OpenCode2Client.events.test.ts b/apps/server/src/provider/OpenCode2Client.events.test.ts index 5ca237f2a669..ddc3bdbaa0c1 100644 --- a/apps/server/src/provider/OpenCode2Client.events.test.ts +++ b/apps/server/src/provider/OpenCode2Client.events.test.ts @@ -1,56 +1,15 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { TestClock } from "effect/testing"; import { HttpClient, HttpClientResponse } from "effect/http"; import { describe } from "vite-plus/test"; import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client"; -const RECORDING = new URL( - "../orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson", - import.meta.url, -); -const Entry = Schema.Struct({ type: Schema.String, frame: Schema.optional(Schema.Unknown) }); -const decodeEntry = Schema.decodeUnknownSync(Schema.fromJsonString(Entry)); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); -const newerServerStream = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const recorded = (yield* fs.readFileString(RECORDING.pathname)) - .split("\n") - .filter((line) => line.length > 0) - .map((line) => decodeEntry(line)) - .flatMap((entry) => { - const frame = entry.frame as { readonly type?: string; readonly event?: unknown } | undefined; - return frame?.type === "sdk.event" ? [frame.event as Record] : []; - }); - const executionStarted = recorded.findIndex( - (event) => event.type === "session.execution.started", - ); - const [first] = recorded; - return [ - ...recorded.slice(0, executionStarted), - { - id: "evt_0000000000newtype", - created: 1, - type: "session.hologram.projected", - data: { beams: 3 }, - }, - { - ...recorded[executionStarted], - data: { ...(recorded[executionStarted]!.data as object), lane: "fast" }, - }, - ...recorded.slice(executionStarted + 1), - first, - ]; -}); - const layerServing = (events: ReadonlyArray) => Layer.succeed( HttpClient.HttpClient, @@ -71,49 +30,7 @@ const connect = Effect.gen(function* () { return yield* opencode.connect({ baseUrl: "http://127.0.0.1:4096", password: "secret" }); }); -const layerServingThenSilent = (events: ReadonlyArray) => - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response( - new ReadableStream({ - start(controller) { - for (const event of events) { - controller.enqueue(new TextEncoder().encode(`data: ${encodeJson(event)}\n\n`)); - } - }, - }), - { headers: { "content-type": "text/event-stream" } }, - ), - ), - ), - ), - ); - describe("OpenCode2Client events", () => { - it.effect("fails a stream that stops sending anything, even heartbeats", () => - Effect.gen(function* () { - const { events } = yield* connect.pipe( - Effect.provide( - OpenCode2Client.layer.pipe( - Layer.provide( - layerServingThenSilent([{ id: "evt_1", type: "server.connected", data: {} }]), - ), - ), - ), - ); - const drained = yield* (yield* events).pipe(Stream.runDrain, Effect.flip, Effect.forkChild); - // Let the first frame arrive and the timer start before the clock moves. - for (let i = 0; i < 20; i++) yield* Effect.yieldNow; - yield* TestClock.adjust("46 seconds"); - const failure = yield* Fiber.join(drained); - assert.strictEqual(failure._tag, "OpenCode2SilentStreamError"); - }).pipe(Effect.provide(TestClock.layer())), - ); - it.effect( "keeps an execution's end and start it cannot decode, and skips other unknown events", () => @@ -159,33 +76,4 @@ describe("OpenCode2Client events", () => { ]); }), ); - - it.effect("skips events a newer server adds and still sees the turn end", () => - Effect.gen(function* () { - const events = yield* newerServerStream; - const { events: subscribe } = yield* connect.pipe( - Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerServing(events)))), - ); - const types = yield* (yield* subscribe).pipe( - Stream.map((event) => event.type), - Stream.runCollect, - ); - - assert.notInclude(types, "session.hologram.projected"); - assert.include(types, "session.execution.started"); - assert.include(types, "session.execution.succeeded"); - assert.strictEqual(types.length, events.length - 1); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("the client's own subscription fails the whole stream on the same events", () => - Effect.gen(function* () { - const events = yield* newerServerStream; - const { client } = yield* connect.pipe( - Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerServing(events)))), - ); - const failure = yield* client.event.subscribe().pipe(Stream.runDrain, Effect.flip); - assert.strictEqual(failure._tag, "ClientError"); - }).pipe(Effect.provide(NodeServices.layer)), - ); }); diff --git a/apps/server/src/provider/ProjectSettingsOverrideSweep.test.ts b/apps/server/src/provider/ProjectSettingsOverrideSweep.test.ts deleted file mode 100644 index 01fd35c0c598..000000000000 --- a/apps/server/src/provider/ProjectSettingsOverrideSweep.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { DEFAULT_SERVER_SETTINGS, ProjectId, type ServerSettings } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { findOrphanedProjectIds } from "./ProjectSettingsOverrideSweep.ts"; - -const liveProjectId = ProjectId.make("project-live"); -const deletedProjectId = ProjectId.make("project-deleted"); - -function settingsWith(overrides: Partial): ServerSettings { - return { ...DEFAULT_SERVER_SETTINGS, ...overrides }; -} - -describe("findOrphanedProjectIds", () => { - it("reports a project override whose project is no longer live", () => { - const settings = settingsWith({ - projectSettingsOverrides: { - [liveProjectId]: { providerInstances: {} }, - [deletedProjectId]: { providerInstances: {} }, - }, - }); - - const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId])); - - expect(orphaned).toEqual([deletedProjectId]); - }); - - it("reports nothing when every override's project is still live", () => { - const settings = settingsWith({ - projectSettingsOverrides: { - [liveProjectId]: { providerInstances: {} }, - }, - }); - - const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId])); - - expect(orphaned).toEqual([]); - }); - - it("reports nothing when there are no overrides at all", () => { - const settings = settingsWith({ projectSettingsOverrides: {} }); - - const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId])); - - expect(orphaned).toEqual([]); - }); -}); diff --git a/apps/server/src/provider/ProjectSettingsOverrideSweep.ts b/apps/server/src/provider/ProjectSettingsOverrideSweep.ts index ae42e9b00c9c..aa7d20527149 100644 --- a/apps/server/src/provider/ProjectSettingsOverrideSweep.ts +++ b/apps/server/src/provider/ProjectSettingsOverrideSweep.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as Settings from "../serverSettings.ts"; -export const findOrphanedProjectIds = ( +const findOrphanedProjectIds = ( settings: Pick, liveProjectIds: ReadonlySet, ): ReadonlyArray => diff --git a/apps/server/src/provider/ProviderAuthService.test.ts b/apps/server/src/provider/ProviderAuthService.test.ts index 5f7dcd4410cf..a5f8ff25aad5 100644 --- a/apps/server/src/provider/ProviderAuthService.test.ts +++ b/apps/server/src/provider/ProviderAuthService.test.ts @@ -451,27 +451,6 @@ describe("ProviderAuthService", () => { assert.include(error.detail, "does not accept"); }), ); - it.effect("stops routed sessions before sign-in, including for a disabled instance", () => - Effect.gen(function* () { - const { service, actions, released } = yield* makeHarness({ - enabled: false, - threads: [makeThread("active")], - sessions: new Map([[ThreadId.make("active"), [makeSession("active-session")]]]), - }); - const state = yield* service.start({ instanceId }, owner); - - assert.strictEqual(state.instanceId, instanceId); - assert.strictEqual(state.phase, "waiting"); - assert.deepStrictEqual(released, ["active-session"]); - assert.deepStrictEqual(actions, [ - "close-gate", - "list-threads", - "sessions:active", - "stop:active-session", - "start-sign-in", - ]); - }), - ); it.effect("keeps sign-in state private and accepts the owner's redirect URL", () => Effect.gen(function* () { @@ -689,42 +668,6 @@ describe("ProviderAuthService", () => { }), ); - it.effect.each([ - { text: "/logout", hasAttachments: false, handled: true }, - { text: " \n/logout\t", hasAttachments: false, handled: true }, - { text: "/logout", hasAttachments: true, handled: false }, - { text: "/logout please", hasAttachments: false, handled: false }, - { text: "Explain /logout", hasAttachments: false, handled: false }, - { text: "/Logout", hasAttachments: false, handled: false }, - ])("handles only a standalone logout command %#", ({ text, hasAttachments, handled }) => - Effect.gen(function* () { - const { service, actions } = yield* makeHarness(); - const result = yield* service.tryHandlePromptCommand({ instanceId, text, hasAttachments }); - - assert.strictEqual(result, handled); - assert.deepStrictEqual( - actions, - handled ? ["close-gate", "list-threads", "native-logout"] : [], - ); - }), - ); - - it.effect("does not intercept commands for providers without an auth controller", () => - Effect.gen(function* () { - const { service, actions } = yield* makeHarness(); - for (const id of [unsupportedInstanceId, ProviderInstanceId.make("missing")]) { - assert.isFalse( - yield* service.tryHandlePromptCommand({ - instanceId: id, - text: "/logout", - hasAttachments: false, - }), - ); - } - assert.deepStrictEqual(actions, []); - }), - ); - it.effect("does not log out when the thread shell cannot be read", () => Effect.gen(function* () { const { service, actions } = yield* makeHarness({ shellError: true }); @@ -761,22 +704,6 @@ describe("ProviderAuthService", () => { ]); }), ); - - it.effect("returns native logout failures to the command caller", () => - Effect.gen(function* () { - const logoutError = new ProviderSetupError({ - instanceId, - operation: "logout", - detail: "Native sign-out failed. Try again.", - }); - const { service } = yield* makeHarness({ logoutError }); - const error = yield* Effect.flip( - service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }), - ); - - assert.strictEqual(error, logoutError); - }), - ); }); it.effect("queued logout prompts resolve the current controller after provider replacement", () => @@ -892,46 +819,6 @@ it.effect.each(["start", "logout", "prompt"] as const)( }).pipe(Effect.scoped), ); -it.effect.each([ - { owner: "provider" as const, key: "different-binding" }, - { owner: "t3" as const, key: "shared" }, -])( - "does not invalidate a peer that switches credential binding during session draining %#", - (binding) => - Effect.gen(function* () { - const draining = yield* Deferred.make(); - const continueDrain = yield* Deferred.make(); - const harness = yield* makeHarness({ - sharedCredentials: true, - ...sessionThreads([makeSession("shared-draining", "ready", otherInstanceId)]), - beforeStop: Deferred.succeed(draining, undefined).pipe( - Effect.andThen(Deferred.await(continueDrain)), - ), - }); - const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); - yield* Deferred.await(draining); - let replacementInvalidated = false; - harness.replaceInstance( - makeInstance({ - instanceId: otherInstanceId, - enabled: true, - auth: { - ...harness.auth, - credentialBinding: binding, - invalidate: Effect.sync(() => { - replacementInvalidated = true; - }), - }, - }), - ); - yield* Deferred.succeed(continueDrain, undefined); - yield* Fiber.join(logout); - assert.isFalse(replacementInvalidated); - assert.isFalse(harness.sessions.has(ThreadId.make("shared-draining"))); - assert.include(harness.actions, "native-logout"); - }).pipe(Effect.scoped), -); - it.effect.each(["selection", "drain"] as const)( "preserves replacement peer sessions when credentials change during session %s", (phase) => diff --git a/apps/server/src/provider/ProviderAuthService.ts b/apps/server/src/provider/ProviderAuthService.ts index 24f176dd7095..5a730c5baf8d 100644 --- a/apps/server/src/provider/ProviderAuthService.ts +++ b/apps/server/src/provider/ProviderAuthService.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -267,8 +268,8 @@ export const makeProviderAuthService = Effect.gen(function* () { }), ); }), - subscribe: (input, ownerSessionId) => - Effect.gen(function* () { + subscribe: Function.flow( + Effect.fn("ProviderAuthService.subscribe")(function* (input, ownerSessionId) { const changes = yield* registry.subscribeChanges; const initial = yield* getController(input.instanceId, "subscribe"); return Stream.concat( @@ -280,7 +281,9 @@ export const makeProviderAuthService = Effect.gen(function* () { Stream.changesWith((previous, next) => previous === next), Stream.switchMap((auth) => auth.subscribe(ownerSessionId)), ); - }).pipe(Stream.unwrap), + }), + Stream.unwrap, + ), tryHandlePromptCommand: Effect.fn("ProviderAuthService.tryHandlePromptCommand")( function* (input) { const instance = yield* registry.getInstance(input.instanceId); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 479ec05f0fb4..01a3bdc63b93 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -15,8 +15,6 @@ import { } from "@t3tools/contracts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; -import type { GrokSettings } from "@t3tools/provider-grok/settings"; -import type { CursorSettings } from "@t3tools/provider-cursor/settings"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -28,18 +26,11 @@ import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; -import * as AntigravityInstallation from "./AntigravityInstallation.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; -import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; -import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server"; -import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; -import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings"; import * as ModelManifest from "./ModelManifest.ts"; -import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; -import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts"; @@ -106,28 +97,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => customModels: [], launchArgs: "", autoCompactWindow: "", - ...overrides, -}); - -const makeCursorConfig = (overrides: Partial): CursorSettings => ({ - enabled: false, - customModels: [], - ...overrides, -}); - -const makeGrokConfig = (overrides: Partial): GrokSettings => ({ - enabled: false, - binaryPath: "grok", - customModels: [], - ...overrides, -}); - -const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ - enabled: false, - binaryPath: "opencode", - serverUrl: "", - serverPassword: "", - customModels: [], + idleCompaction: "default", ...overrides, }); @@ -218,79 +188,6 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { Layer.provideMerge(layerBase), ); - it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => - Effect.gen(function* () { - const personalId = ProviderInstanceId.make("codex_personal"); - const workId = ProviderInstanceId.make("codex_work"); - const codexDriverKind = ProviderDriverKind.make("codex"); - - const configMap: ProviderInstanceConfigMap = { - [personalId]: { - driver: codexDriverKind, - displayName: "Codex (personal)", - enabled: false, - config: makeCodexConfig({ - binaryPath: "/opt/codex-personal/bin/codex", - homePath: "/home/julius/.codex_personal", - customModels: ["personal-preview"], - }), - }, - [workId]: { - driver: codexDriverKind, - displayName: "Codex (work)", - enabled: false, - config: makeCodexConfig({ - binaryPath: "/opt/codex-work/bin/codex", - homePath: "/home/julius/.codex", - customModels: ["work-preview"], - }), - }, - }; - - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver], - configMap, - }); - - const instances = yield* registry.listInstances; - expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [personalId, workId].toSorted(), - ); - expect(instances.every((instance) => instance.driverKind === codexDriverKind)).toBe(true); - expect(instances.map((instance) => instance.displayName).toSorted()).toEqual( - ["Codex (personal)", "Codex (work)"].toSorted(), - ); - - const personal = yield* registry.getInstance(personalId); - const work = yield* registry.getInstance(workId); - expect(personal).toBeDefined(); - expect(work).toBeDefined(); - expect(personal!.orchestrationAdapter).not.toBe(work!.orchestrationAdapter); - expect(personal!.textGeneration).not.toBe(work!.textGeneration); - expect(personal!.snapshot).not.toBe(work!.snapshot); - - const personalSnapshot = yield* personal!.snapshot.getSnapshot; - expect(personalSnapshot.instanceId).toBe(personalId); - expect(personalSnapshot.driver).toBe(codexDriverKind); - expect(personalSnapshot.enabled).toBe(false); - const path = yield* Path.Path; - expect(personalSnapshot.continuation?.groupKey).toBe( - `codex:home:${path.resolve("/home/julius/.codex_personal")}`, - ); - - const workSnapshot = yield* work!.snapshot.getSnapshot; - expect(workSnapshot.instanceId).toBe(workId); - expect(workSnapshot.driver).toBe(codexDriverKind); - expect(workSnapshot.enabled).toBe(false); - expect(workSnapshot.continuation?.groupKey).toBe( - `codex:home:${path.resolve("/home/julius/.codex")}`, - ); - - const unavailable = yield* registry.listUnavailable; - expect(unavailable).toEqual([]); - }).pipe(Effect.provide(layerTest)), - ); - it.live("treats an explicit in-config enabled:false as disabling despite the envelope", () => Effect.gen(function* () { const staleId = ProviderInstanceId.make("codex_stale"); @@ -315,42 +212,6 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }).pipe(Effect.provide(layerTest)), ); - it.live("reports Codex's answer when a redemption changed nothing", () => - Effect.gen(function* () { - if (yield* HostProcess.isWindows) return; - const fileSystem = yield* FileSystem.FileSystem; - const fixtures = yield* makeTildeProviderFixtures(); - yield* fileSystem.writeFileString( - fixtures.codexScriptPath, - JSON.stringify({ - rootThreadId: "probe-thread", - notifications: [], - account: { type: "chatgpt", email: "test@example.com", planType: "plus" }, - failRateLimitsRead: true, - resetCreditOutcome: "alreadyRedeemed", - }), - ); - const codexId = ProviderInstanceId.make("codex_reset"); - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver], - configMap: { - [codexId]: { - driver: ProviderDriverKind.make("codex"), - enabled: true, - environment: [ - { name: "T3_CODEX_COLLAB_SCRIPT", value: fixtures.codexScriptPath, sensitive: false }, - ], - config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), - }, - }, - }); - const codex = yield* registry.getInstance(codexId); - expect(codex).toBeDefined(); - yield* codex!.snapshot.refresh; - expect(yield* codex!.consumeResetCredit!()).toBe("alreadyRedeemed"); - }).pipe(Effect.provide(layerTest)), - ); - it.live("runs Codex and Claude readiness probes from configured tilde paths", () => Effect.gen(function* () { if (yield* HostProcess.isWindows) return; @@ -666,6 +527,79 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }), ); + const makeClaudeCacheFixture = Effect.fnUntraced(function* ( + state: "ready" | "signed-out" | "usage-failed", + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixtures = yield* makeTildeProviderFixtures(); + const recovered = path.join(fixtures.claudeHomePath, "recovered"); + const probeLog = path.join(fixtures.claudeHomePath, "probes.log"); + const instanceId = ProviderInstanceId.make("claude_cache"); + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [ClaudeDriver], + configMap: { + [instanceId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + environment: [ + { name: "T3_CLAUDE_PROBE_LOG", value: probeLog, sensitive: false }, + ...(state === "ready" + ? [] + : [ + { + name: + state === "signed-out" + ? "T3_CLAUDE_LOGIN_MARKER" + : "T3_CLAUDE_USAGE_FAILS_UNTIL", + value: recovered, + sensitive: false, + }, + ]), + ], + config: makeClaudeConfig({ + enabled: true, + binaryPath: fixtures.claudeBinaryPath, + homePath: fixtures.claudeHomePath, + }), + }, + }, + }); + const instance = yield* registry.getInstance(instanceId); + expect(instance).toBeDefined(); + return { + refresh: instance!.snapshot.refresh, + recover: fs.writeFileString(recovered, ""), + probeLog: fs.readFileString(probeLog), + }; + }); + + for (const state of ["signed-out", "usage-failed"] as const) { + it.effect.skipIf(windowsHost)( + state === "signed-out" + ? "shows Claude usage on the first refresh after signing back in" + : "retries Claude usage on the first refresh after a failed usage read", + () => + Effect.gen(function* () { + const fixture = yield* makeClaudeCacheFixture(state); + const before = yield* fixture.refresh; + if (state === "signed-out") { + expect(before.auth.status).toBe("unauthenticated"); + expect(before.usageLimits).toBeUndefined(); + } else { + expect(before.auth.status).toBe("authenticated"); + expect(before.usageLimits?.unavailable?.reason).toBe("probeFailed"); + } + + yield* fixture.recover; + const after = yield* fixture.refresh; + expect(after.auth.status).toBe("authenticated"); + expect(after.usageLimits?.unavailable).toBeUndefined(); + expect(after.usageLimits?.windows.map((window) => window.id)).toEqual(["five_hour"]); + }).pipe(Effect.provide(layerTest)), + ); + } + it.live( "shadows instances whose driver is not registered in this build without failing boot", () => @@ -706,196 +640,3 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }).pipe(Effect.provide(layerTest)), ); }); - -describe("ProviderInstanceRegistry — all drivers slice", () => { - const layerInfra = OpenCodeRuntime.layer.pipe( - Layer.provide(OpenCodeServerLedger.layerTest), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge( - Layer.mock(CodexInstallation.CodexInstallation)({ - managedDirectory: "unused-managed-installation", - }), - ), - Layer.provideMerge( - Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { - getEnvironmentId: Effect.succeed( - EnvironmentId.make("00000000-0000-4000-8000-000000000001"), - ), - }), - ), - ); - const layerBaseDeps = AntigravityInstallation.AntigravityInstallation.layer.pipe( - Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "provider-instance-registry-all-drivers-test", - }), - ), - Layer.provideMerge(layerInfra), - Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(ServerSettings.layerTest()), - Layer.provideMerge(layerTestHttpClient), - Layer.provideMerge(ServerSettings.layerTest()), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ), - Layer.provideMerge(ProviderLatestVersions.layer), - Layer.provideMerge(McpProviderSessions.layer), - Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(ResetCreditCoordinator.layerTest), - ); - const layerBase = ProviderHostLive.layer.pipe(Layer.provideMerge(layerBaseDeps)); - const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( - Layer.provideMerge(layerBase), - ); - - it.live("boots one instance of every shipped driver from a single config map", () => - Effect.gen(function* () { - const codexId = ProviderInstanceId.make("codex_default"); - const claudeId = ProviderInstanceId.make("claude_default"); - const cursorId = ProviderInstanceId.make("cursor_default"); - const grokId = ProviderInstanceId.make("grok_default"); - const openCodeId = ProviderInstanceId.make("opencode_default"); - - const codexDriverKind = ProviderDriverKind.make("codex"); - const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); - const cursorDriverKind = ProviderDriverKind.make("cursor"); - const grokDriverKind = ProviderDriverKind.make("grok"); - const openCodeDriverKind = ProviderDriverKind.make("opencode"); - - const configMap: ProviderInstanceConfigMap = { - [codexId]: { - driver: codexDriverKind, - displayName: "Codex", - enabled: false, - config: makeCodexConfig({ homePath: "/home/julius/.codex" }), - }, - [claudeId]: { - driver: claudeDriverKind, - displayName: "Claude", - enabled: false, - config: makeClaudeConfig({ - homePath: "/home/julius/.claude-work", - launchArgs: "--verbose", - }), - }, - [cursorId]: { - driver: cursorDriverKind, - displayName: "Cursor", - enabled: false, - config: makeCursorConfig({}), - }, - [grokId]: { - driver: grokDriverKind, - displayName: "Grok", - enabled: false, - config: makeGrokConfig({}), - }, - [openCodeId]: { - driver: openCodeDriverKind, - displayName: "OpenCode", - enabled: false, - config: makeOpenCodeConfig({}), - }, - }; - - const { registry } = yield* makeProviderInstanceRegistry< - CodexDriverEnv | ClaudeDriverEnv | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv - >({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], - configMap, - }); - - const unavailable = yield* registry.listUnavailable; - expect(unavailable).toEqual([]); - - const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); - expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), - ); - - const codex = yield* registry.getInstance(codexId); - const claude = yield* registry.getInstance(claudeId); - const cursor = yield* registry.getInstance(cursorId); - const grok = yield* registry.getInstance(grokId); - const openCode = yield* registry.getInstance(openCodeId); - expect(codex?.driverKind).toBe(codexDriverKind); - expect(claude?.driverKind).toBe(claudeDriverKind); - expect(cursor?.driverKind).toBe(cursorDriverKind); - expect(grok?.driverKind).toBe(grokDriverKind); - expect(openCode?.driverKind).toBe(openCodeDriverKind); - expect(codex?.displayName).toBe("Codex"); - expect(claude?.displayName).toBe("Claude"); - expect(cursor?.displayName).toBe("Cursor"); - expect(grok?.displayName).toBe("Grok"); - expect(openCode?.displayName).toBe("OpenCode"); - - const adapters = [ - codex!.orchestrationAdapter, - claude!.orchestrationAdapter, - cursor!.orchestrationAdapter, - grok!.orchestrationAdapter, - openCode!.orchestrationAdapter, - ]; - expect(new Set(adapters).size).toBe(adapters.length); - const textGenerations = [ - codex!.textGeneration, - claude!.textGeneration, - cursor!.textGeneration, - grok!.textGeneration, - openCode!.textGeneration, - ]; - expect(new Set(textGenerations).size).toBe(textGenerations.length); - const snapshots = [ - codex!.snapshot, - claude!.snapshot, - cursor!.snapshot, - grok!.snapshot, - openCode!.snapshot, - ]; - expect(new Set(snapshots).size).toBe(snapshots.length); - - const codexSnapshot = yield* codex!.snapshot.getSnapshot; - expect(codexSnapshot.instanceId).toBe(codexId); - expect(codexSnapshot.driver).toBe(codexDriverKind); - expect(codexSnapshot.enabled).toBe(false); - expect(codexSnapshot.continuation?.groupKey).toBe( - `codex:home:${(yield* Path.Path).resolve("/home/julius/.codex")}`, - ); - - const claudeSnapshot = yield* claude!.snapshot.getSnapshot; - expect(claudeSnapshot.instanceId).toBe(claudeId); - expect(claudeSnapshot.driver).toBe(claudeDriverKind); - expect(claudeSnapshot.enabled).toBe(false); - expect(claudeSnapshot.continuation?.groupKey).toBe( - `claude:home:${(yield* Path.Path).resolve("/home/julius/.claude-work")}`, - ); - - const cursorSnapshot = yield* cursor!.snapshot.getSnapshot; - expect(cursorSnapshot.instanceId).toBe(cursorId); - expect(cursorSnapshot.driver).toBe(cursorDriverKind); - expect(cursorSnapshot.enabled).toBe(false); - expect(cursorSnapshot.continuation?.groupKey).toBe( - `${cursorDriverKind}:instance:${cursorId}`, - ); - - const grokSnapshot = yield* grok!.snapshot.getSnapshot; - expect(grokSnapshot.instanceId).toBe(grokId); - expect(grokSnapshot.driver).toBe(grokDriverKind); - expect(grokSnapshot.enabled).toBe(false); - expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`); - - const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; - expect(openCodeSnapshot.instanceId).toBe(openCodeId); - expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); - expect(openCodeSnapshot.enabled).toBe(false); - expect(openCodeSnapshot.continuation?.groupKey).toBe( - `${openCodeDriverKind}:instance:${openCodeId}`, - ); - }).pipe(Effect.provide(layerTest)), - ); -}); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index e5bf808faa73..1659e8f8fa14 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -70,98 +70,97 @@ const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown return entry.enabled ?? providerInstanceConfigEnabledFlag(typedConfig) ?? true; }; -const buildEntry = (input: { +const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* (input: { readonly driversById: ReadonlyMap>; readonly parentScope: Scope.Scope; readonly instanceId: ProviderInstanceId; readonly rawInstanceId: string; readonly entry: ProviderInstanceConfig; -}): Effect.Effect< +}): Effect.fn.Return< | { readonly kind: "live"; readonly live: LiveEntry } | { readonly kind: "unavailable"; readonly snapshot: ServerProvider }, never, R -> => - Effect.gen(function* () { - const { driversById, parentScope, instanceId, rawInstanceId, entry } = input; - const driver = driversById.get(entry.driver); - if (!driver) { - return { - kind: "unavailable" as const, - snapshot: yield* buildUnavailableProviderSnapshot({ - driverKind: entry.driver, - instanceId, - displayName: entry.displayName, - accentColor: entry.accentColor, - reason: `Driver '${entry.driver}' is not registered in this build.`, - }), - }; - } - - const decoder = Schema.decodeUnknownEffect(driver.configSchema); - const decodeResult = yield* decoder(entry.config ?? driver.defaultConfig()).pipe(Effect.result); - if (decodeResult._tag === "Failure") { - const issue = decodeResult.failure; - const detail = issue.message ?? String(issue); - yield* Effect.logError("Failed to decode provider instance config", { - instanceId: rawInstanceId, - driver: entry.driver, - detail, - }); - return { - kind: "unavailable" as const, - snapshot: yield* buildUnavailableProviderSnapshot({ - driverKind: entry.driver, - instanceId, - displayName: entry.displayName, - accentColor: entry.accentColor, - reason: `Invalid config for instance '${rawInstanceId}': ${detail}`, - }), - }; - } - - const typedConfig = decodeResult.success; - const childScope = yield* Scope.make(); - yield* Scope.addFinalizer(parentScope, Scope.close(childScope, Exit.void).pipe(Effect.ignore)); +> { + const { driversById, parentScope, instanceId, rawInstanceId, entry } = input; + const driver = driversById.get(entry.driver); + if (!driver) { + return { + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: `Driver '${entry.driver}' is not registered in this build.`, + }), + }; + } - const createResult = yield* driver - .create({ + const decoder = Schema.decodeUnknownEffect(driver.configSchema); + const decodeResult = yield* decoder(entry.config ?? driver.defaultConfig()).pipe(Effect.result); + if (decodeResult._tag === "Failure") { + const issue = decodeResult.failure; + const detail = issue.message ?? String(issue); + yield* Effect.logError("Failed to decode provider instance config", { + instanceId: rawInstanceId, + driver: entry.driver, + detail, + }); + return { + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, instanceId, displayName: entry.displayName, accentColor: entry.accentColor, - environment: entry.environment ?? [], - enabled: resolveEntryEnabled(entry, typedConfig), - config: typedConfig, - }) - .pipe(Effect.provideService(Scope.Scope, childScope), Effect.result); - if (createResult._tag === "Failure") { - yield* Effect.logError("Failed to create provider instance", { - instanceId: rawInstanceId, - driver: entry.driver, - detail: createResult.failure.detail, - }); - yield* Scope.close(childScope, Exit.void).pipe(Effect.ignore); - return { - kind: "unavailable" as const, - snapshot: yield* buildUnavailableProviderSnapshot({ - driverKind: entry.driver, - instanceId, - displayName: entry.displayName, - accentColor: entry.accentColor, - reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`, - }), - }; - } + reason: `Invalid config for instance '${rawInstanceId}': ${detail}`, + }), + }; + } + + const typedConfig = decodeResult.success; + const childScope = yield* Scope.make(); + yield* Scope.addFinalizer(parentScope, Scope.close(childScope, Exit.void).pipe(Effect.ignore)); + const createResult = yield* driver + .create({ + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + environment: entry.environment ?? [], + enabled: resolveEntryEnabled(entry, typedConfig), + config: typedConfig, + }) + .pipe(Effect.provideService(Scope.Scope, childScope), Effect.result); + if (createResult._tag === "Failure") { + yield* Effect.logError("Failed to create provider instance", { + instanceId: rawInstanceId, + driver: entry.driver, + detail: createResult.failure.detail, + }); + yield* Scope.close(childScope, Exit.void).pipe(Effect.ignore); return { - kind: "live" as const, - live: { - instance: createResult.success, - scope: childScope, - entry, - }, + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`, + }), }; - }); + } + + return { + kind: "live" as const, + live: { + instance: createResult.success, + scope: childScope, + entry, + }, + }; +}); const makeReconcile = (input: { readonly state: RegistryState; @@ -263,67 +262,68 @@ const makeReconcile = (input: { }); }; -export const makeProviderInstanceRegistry = (input: { +export const makeProviderInstanceRegistry = Effect.fn( + "ProviderInstanceRegistry.makeProviderInstanceRegistry", +)(function* (input: { readonly drivers: ReadonlyArray>; readonly configMap: ProviderInstanceConfigMap; -}): Effect.Effect< +}): Effect.fn.Return< { readonly registry: ProviderInstanceRegistry["Service"]; readonly mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape; }, never, R | Scope.Scope -> => - Effect.gen(function* () { - const driversById = new Map>( - input.drivers.map((driver) => [driver.driverKind, driver]), - ); +> { + const driversById = new Map>( + input.drivers.map((driver) => [driver.driverKind, driver]), + ); - // Capture the enclosing scope so per-instance child scopes can be - // attached to it at `reconcile` time. Without this, `reconcile` - // called later (e.g. from the hydration layer) would attach child - // scopes to the *caller's* scope instead of the registry's. - const parentScope = yield* Scope.Scope; + // Capture the enclosing scope so per-instance child scopes can be + // attached to it at `reconcile` time. Without this, `reconcile` + // called later (e.g. from the hydration layer) would attach child + // scopes to the *caller's* scope instead of the registry's. + const parentScope = yield* Scope.Scope; - const driverContext = yield* Effect.context(); + const driverContext = yield* Effect.context(); - const entries = yield* Ref.make>(new Map()); - const unavailable = yield* Ref.make>(new Map()); - const changes = yield* PubSub.unbounded(); - yield* Effect.addFinalizer(() => PubSub.shutdown(changes)); + const entries = yield* Ref.make>(new Map()); + const unavailable = yield* Ref.make>(new Map()); + const changes = yield* PubSub.unbounded(); + yield* Effect.addFinalizer(() => PubSub.shutdown(changes)); - const state: RegistryState = { entries, unavailable, changes }; - const reconcileWithR = makeReconcile({ state, driversById, parentScope }); - const reconcile: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["reconcile"] = - (configMap) => reconcileWithR(configMap).pipe(Effect.provideContext(driverContext)); + const state: RegistryState = { entries, unavailable, changes }; + const reconcileWithR = makeReconcile({ state, driversById, parentScope }); + const reconcile: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["reconcile"] = + (configMap) => reconcileWithR(configMap).pipe(Effect.provideContext(driverContext)); - yield* reconcile(input.configMap); + yield* reconcile(input.configMap); - const registry: ProviderInstanceRegistry["Service"] = { - getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)), - listInstances: Ref.get(entries).pipe( - Effect.map( - (map) => - Array.from(map.values(), (live) => live.instance) as ReadonlyArray, - ), + const registry: ProviderInstanceRegistry["Service"] = { + getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)), + listInstances: Ref.get(entries).pipe( + Effect.map( + (map) => + Array.from(map.values(), (live) => live.instance) as ReadonlyArray, ), - listUnavailable: Ref.get(unavailable).pipe( - Effect.map((map) => Array.from(map.values()) as ReadonlyArray), - ), - get streamChanges() { - return Stream.fromPubSub(changes); - }, - get subscribeChanges() { - return PubSub.subscribe(changes); - }, - }; + ), + listUnavailable: Ref.get(unavailable).pipe( + Effect.map((map) => Array.from(map.values()) as ReadonlyArray), + ), + get streamChanges() { + return Stream.fromPubSub(changes); + }, + get subscribeChanges() { + return PubSub.subscribe(changes); + }, + }; - const mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape = { - reconcile, - }; + const mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape = { + reconcile, + }; - return { registry, mutator }; - }); + return { registry, mutator }; +}); export const layer = (input: { readonly drivers: ReadonlyArray>; diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts index 16b3958f3613..b825b29b674d 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts @@ -54,21 +54,6 @@ describe("deriveProviderInstanceConfigMap", () => { }); }); - it("leaves an already machine-enabled instance untouched", () => { - const settings = settingsWith({ - providerInstances: { - [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, - }, - projectSettingsOverrides: { - [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, - }, - }); - - const merged = deriveProviderInstanceConfigMap(settings); - - expect(merged[instanceId]).toBe(settings.providerInstances[instanceId]); - }); - it("does not revive an instance a project override references but settings no longer configure", () => { const settings = settingsWith({ providerInstances: {}, diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index 1dcb20425633..1a722be8e3ec 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -69,25 +69,28 @@ export const deriveProviderInstanceConfigMap = ( return merged as ProviderInstanceConfigMap; }; -const layerSettingsWatcher = Layer.effectDiscard( - Effect.gen(function* () { - const mutator = yield* ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator; - const serverSettings = yield* Settings.ServerSettingsService; - const settingsChanges = yield* serverSettings.subscribeChanges; - yield* settingsChanges.pipe( - Stream.runForEach((next) => - mutator - .reconcile(deriveProviderInstanceConfigMap(next)) - .pipe( +const layerSettingsWatcher = (settingsChanges: Stream.Stream) => + Layer.effectDiscard( + Effect.gen(function* () { + const mutator = yield* ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator; + const serverSettings = yield* Settings.ServerSettingsService; + yield* settingsChanges.pipe( + Stream.runForEach(() => + // The subscription predates the seed read, so a queued change can be + // older than the seed. A failed current read must leave the registry intact. + serverSettings.getSettings.pipe( + Effect.flatMap((current) => + mutator.reconcile(deriveProviderInstanceConfigMap(current)), + ), Effect.catchCause((cause) => Effect.logError("ProviderInstanceRegistry reconcile failed", cause), ), ), - ), - Effect.forkScoped, - ); - }), -); + ), + Effect.forkScoped, + ); + }), + ); export const layer: Layer.Layer< ProviderInstanceRegistry.ProviderInstanceRegistry, @@ -96,6 +99,8 @@ export const layer: Layer.Layer< > = Layer.unwrap( Effect.gen(function* () { const serverSettings = yield* Settings.ServerSettingsService; + // Subscribe before the seed read so saves during registry construction are queued. + const settingsChanges = yield* serverSettings.subscribeChanges; const initialSettings: ServerSettings | undefined = yield* serverSettings.getSettings.pipe( Effect.orElseSucceed(() => undefined), ); @@ -113,7 +118,7 @@ export const layer: Layer.Layer< Layer.provide(ProviderHostLive.layer), ); - return layerSettingsWatcher.pipe(Layer.provideMerge(layerMutable)); + return layerSettingsWatcher(settingsChanges).pipe(Layer.provideMerge(layerMutable)); }), ) as Layer.Layer< ProviderInstanceRegistry.ProviderInstanceRegistry, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 9b9f2b60c987..a278470c286d 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1,13 +1,12 @@ +import { vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert, expect } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Path from "effect/Path"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -18,7 +17,6 @@ import * as Scope from "effect/Scope"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import * as CodexErrors from "effect-codex-app-server/errors"; import { EnvironmentId, ClaudeSettings, @@ -28,11 +26,11 @@ import { ProviderDriverKind, ProviderInstanceId, ServerSettings, - type ServerProvider, + ServerSettingsError, + ServerProvider, type ServerProviderSlashCommand, type ServerSettings as ContractServerSettings, } from "@t3tools/contracts"; -import * as PlatformError from "effect/PlatformError"; import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { deepMerge } from "@t3tools/shared/Struct"; @@ -66,8 +64,12 @@ import type { } from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; -import { ProviderDriverError } from "./Errors.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; +vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); + +const decodeServerProvider = Schema.decodeSync(ServerProvider); +const encodeServerProvider = Schema.encodeSync(ServerProvider); const decodeServerSettings = Schema.decodeSync(ServerSettings); const encodeServerSettings = Schema.encodeSync(ServerSettings); const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); @@ -84,10 +86,6 @@ const disabledDefaultSlots = ( const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); -const decodeCodexSettings = Schema.decodeSync(CodexSettings); -const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({ - enabled: false, -}); process.env.T3CODE_CURSOR_ENABLED = "1"; @@ -182,9 +180,6 @@ function claudeCapabilities(overrides: Partial = {}) { }); } -const noClaudeCapabilities = () => - Effect.sync(() => undefined as TestClaudeCapabilities | undefined); - function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -217,92 +212,6 @@ function layerMockSpawner( ); } -function recordingMockSpawnerLayer( - handler: (args: ReadonlyArray) => { - stdout: string; - stderr: string; - code: number; - }, -) { - const commands: Array<{ - readonly args: ReadonlyArray; - readonly env: NodeJS.ProcessEnv | undefined; - }> = []; - const layer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - const cmd = command as unknown as { - args: ReadonlyArray; - options?: { - readonly env?: NodeJS.ProcessEnv; - }; - }; - commands.push({ args: cmd.args, env: cmd.options?.env }); - return Effect.succeed(mockHandle(handler(cmd.args))); - }), - ); - return { layer, commands }; -} - -function layerMockCommandSpawner( - handler: ( - command: string, - args: ReadonlyArray, - ) => { stdout: string; stderr: string; code: number }, -) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - const cmd = command as unknown as { - command: string; - args: ReadonlyArray; - }; - return Effect.succeed(mockHandle(handler(cmd.command, cmd.args))); - }), - ); -} - -function layerFailingSpawner(description: string) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description, - }), - ), - ), - ); -} - -function layerHangingScopedSpawner(killCalls: Ref.Ref) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const handle = ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.never, - isRunning: Effect.succeed(true), - kill: () => Ref.update(killCalls, (current) => current + 1), - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.never, - stderr: Stream.never, - all: Stream.never, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - return handle; - }), - ), - ); -} - const codexModelCapabilities = createModelCapabilities({ optionDescriptors: [ selectDescriptor("reasoningEffort", "Reasoning", [ @@ -396,6 +305,7 @@ const awaitPersistedProvider = ( ); const layerTestNodeServices = Layer.mergeAll( + ProviderLatestVersions.layer, NodeServices.layer, Layer.mock(CodexInstallation.CodexInstallation)({ managedDirectory: "unused-managed-installation", @@ -434,6 +344,7 @@ it.layer( assert.strictEqual(status.auth.type, "chatgpt"); assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); assert.strictEqual(status.auth.email, "test@example.com"); + assert.deepStrictEqual(status.setup, { canAuthenticate: false, canInstall: false }); assert.deepStrictEqual(status.models, [ { slug: "gpt-live-codex", @@ -466,21 +377,6 @@ it.layer( }), ); - it.effect("passes configured launch args to the Codex provider probe", () => - Effect.gen(function* () { - let observedLaunchArgs: string | undefined; - const settings = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" }); - - const status = yield* checkCodexProviderStatus(settings, (input) => { - observedLaunchArgs = input.launchArgs; - return Effect.succeed(makeCodexProbeSnapshot()); - }); - - assert.strictEqual(status.status, "ready"); - assert.strictEqual(observedLaunchArgs, "--strict-config --enable foo"); - }), - ); - it.effect("returns unauthenticated when app-server requires OpenAI auth", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => @@ -520,117 +416,10 @@ it.layer( assert.strictEqual(status.auth.status, "unknown"); }), ); - - it.effect("returns an api key label for codex api key auth", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: { type: "apiKey" }, - requiresOpenaiAuth: false, - }, - }), - ), - ); - - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "apiKey"); - assert.strictEqual(status.auth.label, "OpenAI API Key"); - }), - ); - - it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: { type: "amazonBedrock" }, - requiresOpenaiAuth: false, - }, - }), - ), - ); - - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "amazonBedrock"); - assert.strictEqual(status.auth.label, "Amazon Bedrock"); - }), - ); - - it.effect.each([ - "codex", - "/Applications/Custom App.app/Contents/Resources/codex", - "C:\\Tools\\codex.exe", - ])("explains how to configure a Codex executable that cannot start: %s", (binaryPath) => - Effect.gen(function* () { - const settings = { ...defaultCodexSettings, binaryPath }; - const status = yield* checkCodexProviderStatus(settings, (input) => { - assert.strictEqual(input.binaryPath, binaryPath); - return Effect.fail( - new CodexErrors.CodexAppServerSpawnError({ - command: `${binaryPath} app-server`, - cause: new Error("spawn ENOENT"), - }), - ); - }); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.auth.status, "unknown"); - assert.include(status.message, binaryPath); - assert.include(status.message, "Settings → Providers → Codex → Binary path on the server"); - assert.strictEqual( - status.message?.includes("Installing ChatGPT or Codex desktop"), - binaryPath === "codex", - ); - assert.strictEqual(settings.binaryPath, binaryPath); - }), - ); - - it.effect( - "does not time out a healthy probe slower than the shared 10s auth-probe timeout (#7513)", - () => - Effect.gen(function* () { - const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.sleep("15 seconds").pipe(Effect.as(makeCodexProbeSnapshot())), - ).pipe(Effect.forkChild); - - yield* Effect.yieldNow; - yield* TestClock.adjust("15 seconds"); - - const status = yield* Fiber.join(statusFiber); - assert.strictEqual(status.status, "ready"); - }), - ); - - it.effect("closes the app-server probe scope when provider status times out", () => - Effect.gen(function* () { - const killCalls = yield* Ref.make(0); - const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( - Effect.provide(layerHangingScopedSpawner(killCalls)), - Effect.forkChild, - ); - - yield* Effect.yieldNow; - yield* TestClock.adjust("21 seconds"); - yield* Effect.yieldNow; - - const status = yield* Fiber.join(statusFiber); - assert.strictEqual(status.status, "error"); - assert.strictEqual( - status.message, - "Timed out while checking Codex app-server provider status.", - ); - assert.strictEqual(yield* Ref.get(killCalls), 1); - }), - ); }); describe("ProviderRegistry.layer", () => { - it("stores workspace skills and commands without changing machine metadata", () => { + it("stores workspace skills, commands and options without changing machine metadata", () => { const provider = { instanceId: ProviderInstanceId.make("codex"), driver: ProviderDriverKind.make("codex"), @@ -649,7 +438,15 @@ it.layer( checkedAt: "2026-03-25T00:01:00.000Z", slashCommands: [{ name: "project" }], skills: [{ name: "project", path: "/project/SKILL.md", enabled: true }], - } satisfies ServerProvider; + optionDescriptors: [ + { + id: "agent", + label: "Agent", + type: "select", + options: [{ id: "readonly", label: "Readonly" }], + }, + ], + } satisfies ProviderWorkspaceSnapshot; const result = ProviderRegistry.upsertProviderWorkspaceSnapshot( provider, @@ -659,12 +456,15 @@ it.layer( assert.deepStrictEqual(result.slashCommands, provider.slashCommands); assert.deepStrictEqual(result.skills, provider.skills); - assert.deepStrictEqual(result.workspaceSnapshots, [ + assert.strictEqual(result.models, provider.models); + const decoded = decodeServerProvider(encodeServerProvider(result)); + assert.deepStrictEqual(decoded.workspaceSnapshots, [ { cwd: "/project", checkedAt: scopedSnapshot.checkedAt, slashCommands: scopedSnapshot.slashCommands, skills: scopedSnapshot.skills, + optionDescriptors: scopedSnapshot.optionDescriptors, }, ]); @@ -697,6 +497,47 @@ it.layer( { name: "replacement" }, ]); assert.strictEqual(recovered.workspaceSnapshots?.[0]?.slashCommandsPending, undefined); + + const { optionDescriptors, ...failedSnapshot } = scopedSnapshot; + const firstFailure = ProviderRegistry.upsertProviderWorkspaceSnapshot( + provider, + "/project", + failedSnapshot, + ); + assert.strictEqual( + Object.hasOwn(firstFailure.workspaceSnapshots![0]!, "optionDescriptors"), + false, + ); + const failedOtherProject = ProviderRegistry.upsertProviderWorkspaceSnapshot( + recovered, + "/other-project", + failedSnapshot, + ); + assert.strictEqual( + Object.hasOwn(failedOtherProject.workspaceSnapshots![1]!, "optionDescriptors"), + false, + ); + + for (const clearedDescriptors of [[], [{ ...optionDescriptors[0]!, options: [] }]]) { + const cleared = ProviderRegistry.upsertProviderWorkspaceSnapshot(recovered, "/project", { + ...scopedSnapshot, + optionDescriptors: clearedDescriptors, + }); + assert.deepStrictEqual( + cleared.workspaceSnapshots?.[0]?.optionDescriptors, + clearedDescriptors, + ); + } + + const failedRefresh = ProviderRegistry.upsertProviderWorkspaceSnapshot( + failedOtherProject, + "/project", + failedSnapshot, + ); + assert.deepStrictEqual( + failedRefresh.workspaceSnapshots?.[1]?.optionDescriptors, + optionDescriptors, + ); }); it("preserves previously discovered provider models when a refresh returns none", () => { @@ -757,45 +598,6 @@ it.layer( ); }); - it("drops custom models the refreshed snapshot no longer carries", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: ProviderDriverKind.make("claudeAgent"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2.1.0", - models: [ - { - slug: "claude-sonnet-4-6", - name: "Sonnet 4.6", - isCustom: false, - capabilities: null, - }, - { - slug: "removed-custom", - name: "removed-custom", - isCustom: true, - capabilities: null, - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-04-14T00:01:00.000Z", - models: [previousProvider.models[0]], - } satisfies ServerProvider; - - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, - [...refreshedProvider.models], - ); - }); - it("does not bring back models the installed CLI is too old to run", () => { const pendingProvider = { instanceId: ProviderInstanceId.make("claudeAgent"), @@ -829,73 +631,6 @@ it.layer( ); }); - it("drops stale ACP Registry models missing from a completed discovery probe", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("acpRegistry_codex"), - driver: ProviderDriverKind.make("acpRegistry"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-08-13T00:00:00.000Z", - version: "1.2.0", - models: [ - { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, - { - slug: "gpt-5.6-sol[low]", - name: "GPT-5.6-Sol (low)", - isCustom: false, - capabilities: null, - }, - { slug: "default", name: "Default", isCustom: false, capabilities: null }, - ], - nativeSessions: { canList: false, canLoad: false, canResume: false }, - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-08-13T00:01:00.000Z", - models: [{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }], - } satisfies ServerProvider; - - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, - [...refreshedProvider.models], - ); - }); - - it("drops stale ACP Registry models when an agent without sign-in completes discovery", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("acpRegistry_kimi"), - driver: ProviderDriverKind.make("acpRegistry"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "unknown", canLogout: false }, - checkedAt: "2026-08-13T00:00:00.000Z", - version: "1.2.0", - models: [ - { slug: "kimi-old", name: "Kimi Old", isCustom: false, capabilities: null }, - { slug: "default", name: "Default", isCustom: false, capabilities: null }, - ], - nativeSessions: { canList: true, canLoad: true, canResume: false }, - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - // Agents that advertise no auth methods stay "unknown" after every probe. - const probedProvider = { - ...previousProvider, - checkedAt: "2026-08-13T00:01:00.000Z", - models: [{ slug: "kimi-new", name: "Kimi New", isCustom: false, capabilities: null }], - } satisfies ServerProvider; - - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, probedProvider).models, - [...probedProvider.models], - ); - }); - it("retains ACP Registry models while discovery has not completed", () => { const previousProvider = { instanceId: ProviderInstanceId.make("acpRegistry_codex"), @@ -952,7 +687,7 @@ it.layer( ); }); - it("drops stale OpenCode models missing from a successful refresh", () => { + it("retains stale OpenCode models when a refresh fails", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), driver: ProviderDriverKind.make("opencode"), @@ -970,197 +705,38 @@ it.layer( isCustom: false, capabilities: null, }, + ], + slashCommands: [{ name: "review", description: "Review changes" }], + skills: [ { - slug: "removed-plugin/model", - name: "Removed Plugin Model", - subProvider: "Removed Plugin", - isCustom: false, - capabilities: null, + name: "typescript", + description: "TypeScript help", + path: "/skills/typescript/SKILL.md", + enabled: true, }, ], - slashCommands: [], - skills: [], } as const satisfies ServerProvider; const refreshedProvider = { ...previousProvider, + status: "error", + auth: { status: "unknown" }, checkedAt: "2026-07-17T00:01:00.000Z", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - ], + models: [], + message: "Failed to refresh OpenCode models.", } satisfies ServerProvider; assert.deepStrictEqual( ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, - [...refreshedProvider.models], + [...previousProvider.models], + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, + previousProvider.slashCommands, + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).skills, + previousProvider.skills, ); - }); - - it("retains stale OpenCode models when a refresh fails", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("opencode"), - driver: ProviderDriverKind.make("opencode"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-07-17T00:00:00.000Z", - version: "1.0.0", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - ], - slashCommands: [{ name: "review", description: "Review changes" }], - skills: [ - { - name: "typescript", - description: "TypeScript help", - path: "/skills/typescript/SKILL.md", - enabled: true, - }, - ], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - status: "error", - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:01:00.000Z", - models: [], - message: "Failed to refresh OpenCode models.", - } satisfies ServerProvider; - - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, - [...previousProvider.models], - ); - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, - previousProvider.slashCommands, - ); - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).skills, - previousProvider.skills, - ); - }); - - it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("opencode"), - driver: ProviderDriverKind.make("opencode"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-07-17T00:00:00.000Z", - version: "1.0.0", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - { - slug: "removed-plugin/model", - name: "Removed Plugin Model", - subProvider: "Removed Plugin", - isCustom: false, - capabilities: null, - }, - ], - slashCommands: [{ name: "review", description: "Review changes" }], - skills: [ - { - name: "typescript", - description: "TypeScript help", - path: "/skills/typescript/SKILL.md", - enabled: true, - }, - ], - } as const satisfies ServerProvider; - const pendingProvider = { - ...previousProvider, - status: "warning", - installed: false, - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:01:00.000Z", - version: null, - models: [], - message: "OpenCode provider status has not been checked in this session yet.", - } satisfies ServerProvider; - const loggedOutProvider = { - ...previousProvider, - status: "warning", - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:02:00.000Z", - models: [], - slashCommands: [], - skills: [], - message: "OpenCode is available, but it did not report any connected upstream providers.", - } satisfies ServerProvider; - const missingProvider = { - ...previousProvider, - status: "error", - installed: false, - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:03:00.000Z", - version: null, - models: [], - message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", - } satisfies ServerProvider; - const authoritativeProvider = { - ...previousProvider, - checkedAt: "2026-07-17T00:04:00.000Z", - models: [previousProvider.models[0]!], - } satisfies ServerProvider; - const failedProvider = { - ...authoritativeProvider, - status: "error", - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:05:00.000Z", - models: [], - message: "Failed to refresh OpenCode models.", - } satisfies ServerProvider; - - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, pendingProvider).models, - [...previousProvider.models], - ); - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, loggedOutProvider).models, - [], - ); - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, loggedOutProvider).slashCommands, - [], - ); - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, loggedOutProvider).skills, - [], - ); - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, missingProvider).models, - [], - ); - - const afterRemoval = ProviderRegistry.mergeProviderSnapshot( - previousProvider, - authoritativeProvider, - ); - const afterFailure = ProviderRegistry.mergeProviderSnapshot(afterRemoval, failedProvider); - - assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); }); describe("Pi model inventories", () => { @@ -1226,27 +802,6 @@ it.layer( message: "Pi is available, but T3 Code could not refresh its models and commands.", } satisfies ServerProvider; - it("drops disconnected Pi upstream models after successful discovery", () => { - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(cachedProvider, refreshedProvider).models, - refreshedProvider.models, - ); - }); - - it("clears discovered Pi models when successful discovery finds no usable models", () => { - const signedOutProvider = { - ...refreshedProvider, - status: "warning", - auth: { status: "unauthenticated", type: "pi" }, - models: [defaultModel, customModel], - } satisfies ServerProvider; - - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(cachedProvider, signedOutProvider).models, - signedOutProvider.models, - ); - }); - it("retains Pi inventories during incomplete probes without restoring removed custom models", () => { const incompleteProviders = [ pendingProvider, @@ -1406,51 +961,6 @@ it.layer( installed: true, } satisfies ServerProvider; - it("drops retired alpha models after discovery, including without OpenAI authentication", () => { - for (const authStatus of ["authenticated", "unknown"] as const) { - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(cachedProvider, { - ...refreshedProvider, - auth: { status: authStatus }, - }).models, - refreshedProvider.models, - ); - } - }); - - it("keeps discovered models during startup and failed probes without restoring removed custom models", () => { - for (const provider of [pendingProvider, failedProvider]) { - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot( - { - ...cachedProvider, - models: [...cachedProvider.models, { ...customModel, slug: "removed-custom" }], - }, - provider, - ).models, - [customModel, ...cachedProvider.models], - ); - } - }); - - it("clears discovered models after sign-out, disable, uninstall, or empty discovery", () => { - const emptyProvider = { ...refreshedProvider, models: [customModel] }; - const clearedProviders = [ - { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, - { ...emptyProvider, status: "disabled", enabled: false }, - { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, - emptyProvider, - { ...emptyProvider, models: [] }, - ] satisfies ReadonlyArray; - - for (const provider of clearedProviders) { - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(cachedProvider, provider).models, - provider.models, - ); - } - }); - it.effect("persists removals across failed refreshes and registry restarts", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -1565,65 +1075,6 @@ it.layer( skills: [], } as const satisfies ServerProvider; - it("removes unavailable models after a successful refresh", () => { - for (const status of ["ready", "warning"] as const) { - const refreshedProvider = { - ...previousProvider, - status, - checkedAt: "2026-09-02T00:01:00.000Z", - models: [previousProvider.models[1]], - } satisfies ServerProvider; - const afterRefresh = ProviderRegistry.mergeProviderSnapshot( - previousProvider, - refreshedProvider, - ); - - assert.deepStrictEqual(afterRefresh.models, refreshedProvider.models); - - const afterFailure = ProviderRegistry.mergeProviderSnapshot(afterRefresh, { - ...refreshedProvider, - status: "error", - auth: { status: "unknown" }, - models: [], - }); - assert.deepStrictEqual(afterFailure.models, refreshedProvider.models); - } - }); - - it("keeps cached models during health checks and temporary failures", () => { - for (const installed of [false, true]) { - const pendingProvider = { - ...previousProvider, - status: "warning", - installed, - auth: { status: "unknown" }, - checkedAt: "2026-09-02T00:01:00.000Z", - version: installed ? previousProvider.version : null, - models: [], - } satisfies ServerProvider; - - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, pendingProvider).models, - previousProvider.models, - ); - } - - for (const authStatus of ["unknown", "authenticated"] as const) { - const failedProvider = { - ...previousProvider, - status: "error", - auth: { status: authStatus }, - checkedAt: "2026-09-02T00:02:00.000Z", - models: [], - } satisfies ServerProvider; - - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, failedProvider).models, - previousProvider.models, - ); - } - }); - it("clears models after sign-out, disable, uninstall, or an empty successful refresh", () => { const emptyProvider = { ...previousProvider, @@ -1701,31 +1152,6 @@ it.layer( ); }); - it("carries the account through the boot probe and a failed probe without hiding them", () => { - const booting = { - ...restartProbe, - installed: false, - version: null, - message: "Checking Antigravity availability.", - } satisfies ServerProvider; - assert.deepStrictEqual(ProviderRegistry.mergeProviderSnapshot(signedIn, booting), { - ...booting, - auth: signedIn.auth, - models: signedIn.models, - }); - - const failed = { - ...restartProbe, - status: "error", - message: "Antigravity did not respond to its local health check within 90 seconds.", - } satisfies ServerProvider; - assert.deepStrictEqual(ProviderRegistry.mergeProviderSnapshot(signedIn, failed), { - ...failed, - auth: signedIn.auth, - models: signedIn.models, - }); - }); - it("does not invent an account after sign-out, disable, uninstall, or for other providers", () => { const untouched = [ { ...restartProbe, auth: { status: "unauthenticated" } }, @@ -1814,372 +1240,48 @@ it.layer( ); }); - it.effect("does not run provider probes during layer construction", () => + it.effect("publishes workspace skills while machine health is in error", () => Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const codexInstanceId = ProviderInstanceId.make("codex"); - const initialProvider = { - instanceId: codexInstanceId, - driver: codexDriver, - status: "warning", + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + const healthyProvider = { + instanceId, + driver, + status: "ready", enabled: true, - installed: false, - auth: { status: "unknown" }, + installed: true, + auth: { status: "authenticated" }, checkedAt: "2026-06-10T00:00:00.000Z", - version: null, - message: "Checking Codex provider status.", + version: "1.0.0", models: [], - slashCommands: [], + slashCommands: [COMPACT_SLASH_COMMAND], skills: [], } as const satisfies ServerProvider; - const refreshCalls = yield* Ref.make(0); - const instance = { - instanceId: codexInstanceId, - driverKind: codexDriver, - continuationIdentity: { - driverKind: codexDriver, - continuationKey: "codex:instance:codex", - }, + // Codex's account check failed; its skill scan did not. + const unhealthyProvider = { + ...healthyProvider, + status: "error", + auth: { status: "unknown" }, + message: "Codex app-server provider probe failed: unauthorized (401).", + slashCommands: [], + } as const satisfies ServerProvider; + const firstSkills = [{ name: "first", path: "/workspace/first/SKILL.md", enabled: true }]; + const laterSkills = [{ name: "later", path: "/workspace/later/SKILL.md", enabled: true }]; + const scanResult = yield* Ref.make({ + ...healthyProvider, + skills: firstSkills, + }); + const instance: ProviderInstance = { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" }, displayName: undefined, enabled: true, snapshot: { resolveMaintenance: () => Effect.succeed( makeManualOnlyProviderMaintenanceCapabilities({ - provider: codexDriver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(initialProvider), - refresh: Ref.update(refreshCalls, (count) => count + 1).pipe( - Effect.andThen(Effect.never), - ), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - } satisfies ProviderInstance; - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Effect.succeed(instanceId === codexInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistry.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-background-refresh-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); - assert.strictEqual(yield* Ref.get(refreshCalls), 0); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("deduplicates cwd probes and clears snapshots when an instance rebuilds", () => - Effect.gen(function* () { - const driver = ProviderDriverKind.make("codex"); - const instanceId = ProviderInstanceId.make("codex"); - const machineProvider = { - instanceId, - driver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [{ name: "global" }], - skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], - } as const satisfies ServerProvider; - const scopedProvider = { - ...machineProvider, - checkedAt: "2026-06-10T00:01:00.000Z", - slashCommands: [{ name: "project" }], - skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }], - } as const satisfies ServerProvider; - const snapshotCalls = yield* Ref.make(0); - const scopedResult = yield* Ref.make({ - ...scopedProvider, - status: "error", - slashCommands: [COMPACT_SLASH_COMMAND], - slashCommandsPending: true, - }); - const cacheInvalidations = yield* Ref.make(0); - const scanGate = yield* Ref.make<{ - readonly started: Deferred.Deferred; - readonly release: Deferred.Deferred; - } | null>(null); - const failScan = yield* Ref.make(true); - const probeStarted = yield* Deferred.make(); - const releaseProbe = yield* Deferred.make(); - const makeInstance = ( - provider: ServerProvider, - snapshotForCwd: NonNullable, - ): ProviderInstance => ({ - instanceId, - driverKind: driver, - continuationIdentity: { - driverKind: driver, - continuationKey: "codex:instance:codex", - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: driver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(provider), - refresh: Effect.succeed(provider), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - snapshotForCwd, - invalidateCaches: Ref.update(cacheInvalidations, (count) => count + 1), - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }); - const firstInstance = makeInstance(machineProvider, () => - Effect.gen(function* () { - yield* Ref.update(snapshotCalls, (count) => count + 1); - if (yield* Ref.get(failScan)) { - return yield* new ProviderDriverError({ - driver, - instanceId, - detail: "The workspace scan failed.", - }); - } - yield* Deferred.succeed(probeStarted, undefined); - yield* Deferred.await(releaseProbe); - const result = yield* Ref.get(scopedResult); - const gate = yield* Ref.getAndSet(scanGate, null); - if (gate) { - yield* Deferred.succeed(gate.started, undefined); - yield* Deferred.await(gate.release); - } - return result; - }), - ); - const rebuiltProvider = { - ...machineProvider, - checkedAt: "2026-06-10T00:02:00.000Z", - status: "warning", - installed: false, - auth: { status: "unknown" }, - } satisfies ServerProvider; - const rebuiltInstance = makeInstance(rebuiltProvider, () => - Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)), - ); - const registryChanges = yield* PubSub.unbounded(); - const instancesRef = yield* Ref.make>([firstInstance]); - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (requestedId) => - Ref.get(instancesRef).pipe( - Effect.map((instances) => - instances.find((instance) => instance.instanceId === requestedId), - ), - ), - listInstances: Ref.get(instancesRef), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.fromPubSub(registryChanges), - subscribeChanges: PubSub.subscribe(registryChanges), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistry.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-workspace-snapshot-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined); - yield* Ref.set(failScan, false); - const workspaceUpdate = yield* registry.streamChanges.pipe( - Stream.runHead, - Effect.forkChild, - ); - yield* Effect.yieldNow; - const firstRefresh = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) - .pipe(Effect.forkChild); - yield* Deferred.await(probeStarted); - const duplicateRefresh = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) - .pipe(Effect.forkChild); - yield* Effect.yieldNow; - assert.strictEqual(yield* Ref.get(snapshotCalls), 2); - yield* Deferred.succeed(releaseProbe, undefined); - yield* Fiber.join(firstRefresh); - yield* Fiber.join(duplicateRefresh); - const published = yield* Fiber.join(workspaceUpdate); - assert.strictEqual(published._tag, "Some"); - const providers = yield* registry.getProviders; - assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills); - assert.deepStrictEqual( - providers[0]?.workspaceSnapshots?.[0]?.skills, - scopedProvider.skills, - ); - assert.deepStrictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommands, [ - COMPACT_SLASH_COMMAND, - ]); - assert.strictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, true); - yield* Ref.set(scopedResult, { - ...scopedProvider, - status: "error", - slashCommandsPending: false, - }); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 3); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommands, - scopedProvider.slashCommands, - ); - assert.strictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, - undefined, - ); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 3); - yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - 1); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 3); - yield* TestClock.adjust(1); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 4); - const newSkills = [ - ...scopedProvider.skills, - { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, - ]; - yield* Ref.set(scopedResult, { ...scopedProvider, skills: newSkills }); - yield* registry.refreshWorkspaceSnapshot({ - instanceId, - cwd: "/workspace", - fresh: true, - }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 5); - assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), - [newSkills], - ); - - const slowStarted = yield* Deferred.make(); - const releaseSlow = yield* Deferred.make(); - yield* Ref.set(scanGate, { started: slowStarted, release: releaseSlow }); - yield* Ref.set(scopedResult, scopedProvider); - const slowScan = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace", fresh: true }) - .pipe(Effect.forkChild); - yield* Deferred.await(slowStarted); - const latestSkills = [ - ...newSkills, - { name: "latest", path: "/workspace/latest/SKILL.md", enabled: true }, - ]; - yield* Ref.set(scopedResult, { ...scopedProvider, skills: latestSkills }); - yield* registry.refreshWorkspaceSnapshot({ - instanceId, - cwd: "/workspace", - fresh: true, - }); - yield* Deferred.succeed(releaseSlow, undefined); - yield* Fiber.join(slowScan); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), - [latestSkills], - ); - - yield* Ref.set(instancesRef, [rebuiltInstance]); - yield* PubSub.publish(registryChanges, undefined); - let rebuilt = yield* registry.getProviders; - for ( - let attempt = 0; - attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt; - attempt += 1 - ) { - yield* Effect.yieldNow; - rebuilt = yield* registry.getProviders; - } - assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt); - assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("publishes workspace skills while machine health is in error", () => - Effect.gen(function* () { - const driver = ProviderDriverKind.make("codex"); - const instanceId = ProviderInstanceId.make("codex"); - const healthyProvider = { - instanceId, - driver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [COMPACT_SLASH_COMMAND], - skills: [], - } as const satisfies ServerProvider; - // Codex's account check failed; its skill scan did not. - const unhealthyProvider = { - ...healthyProvider, - status: "error", - auth: { status: "unknown" }, - message: "Codex app-server provider probe failed: unauthorized (401).", - slashCommands: [], - } as const satisfies ServerProvider; - const firstSkills = [{ name: "first", path: "/workspace/first/SKILL.md", enabled: true }]; - const laterSkills = [{ name: "later", path: "/workspace/later/SKILL.md", enabled: true }]; - const scanResult = yield* Ref.make({ - ...healthyProvider, - skills: firstSkills, - }); - const instance: ProviderInstance = { - instanceId, - driverKind: driver, - continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: driver, + provider: driver, packageName: null, }), ), @@ -2270,17 +1372,13 @@ it.layer( }), ); - it.effect("refreshes OpenCode catalogs and preserves other providers", () => + it.effect("shares one in-flight pass between concurrent untargeted refreshes", () => Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const openCodeDriver = ProviderDriverKind.make("opencode"); - const codexInstanceId = ProviderInstanceId.make("codex"); - const openCodeInstanceId = ProviderInstanceId.make("opencode"); - const codexRefreshCalls = yield* Ref.make(0); - const openCodeRefreshCalls = yield* Ref.make(0); - const codexProvider = { - instanceId: codexInstanceId, - driver: codexDriver, + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + const provider = { + instanceId, + driver, status: "ready", enabled: true, installed: true, @@ -2291,284 +1389,40 @@ it.layer( slashCommands: [], skills: [], } as const satisfies ServerProvider; - const failedOpenCodeProvider = { - instanceId: openCodeInstanceId, - driver: openCodeDriver, - status: "error", - enabled: true, - installed: true, - auth: { status: "unknown" }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: "1.0.0", - message: "Failed to refresh OpenCode models.", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const recoveredOpenCodeProvider = { - ...failedOpenCodeProvider, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-06-10T00:01:00.000Z", - message: "One upstream provider connected through OpenCode.", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - ], - } as const satisfies ServerProvider; - const changedCatalogProvider = { - ...recoveredOpenCodeProvider, - checkedAt: "2026-06-10T00:02:00.000Z", - models: [ - { - slug: "anthropic/claude-sonnet-4", - name: "Claude Sonnet 4", - subProvider: "Anthropic", - isCustom: false, - capabilities: null, - }, - ], - } as const satisfies ServerProvider; - const catalogSnapshot = yield* Ref.make(recoveredOpenCodeProvider); + const refreshCalls = yield* Ref.make(0); + const probeStarted = yield* Deferred.make(); + const releaseProbe = yield* Deferred.make(); const instances = [ { - instanceId: codexInstanceId, - driverKind: codexDriver, - continuationIdentity: { - driverKind: codexDriver, - continuationKey: "codex:instance:codex", - }, + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" }, displayName: undefined, enabled: true, snapshot: { resolveMaintenance: () => Effect.succeed( makeManualOnlyProviderMaintenanceCapabilities({ - provider: codexDriver, + provider: driver, packageName: null, }), ), - getSnapshot: Effect.succeed(codexProvider), - refresh: Ref.update(codexRefreshCalls, (count) => count + 1).pipe( - Effect.as(codexProvider), - ), + getSnapshot: Effect.succeed(provider), + refresh: Effect.gen(function* () { + yield* Ref.update(refreshCalls, (count) => count + 1); + yield* Deferred.succeed(probeStarted, undefined); + yield* Deferred.await(releaseProbe); + return provider; + }), streamChanges: Stream.empty, applyUsageLimits: () => Effect.void, }, orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], textGeneration: {} as ProviderInstance["textGeneration"], }, - { - instanceId: openCodeInstanceId, - driverKind: openCodeDriver, - continuationIdentity: { - driverKind: openCodeDriver, - continuationKey: "opencode:instance:opencode", - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: openCodeDriver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(failedOpenCodeProvider), - refresh: Ref.update(openCodeRefreshCalls, (count) => count + 1).pipe( - Effect.andThen(Ref.get(catalogSnapshot)), - ), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }, - ] satisfies ReadonlyArray; - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)), - listInstances: Effect.succeed(instances), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistry.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-reconnect-refresh-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const initialProviders = yield* registry.getProviders; - assert.strictEqual( - initialProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.status, - "error", - ); - - const recoveredProviders = yield* registry.refresh(); - assert.deepStrictEqual( - recoveredProviders.find((provider) => provider.instanceId === openCodeInstanceId) - ?.models, - recoveredOpenCodeProvider.models, - ); - assert.deepStrictEqual( - recoveredProviders.find((provider) => provider.instanceId === codexInstanceId), - withBundledCompatibility(codexProvider), - ); - - yield* Ref.set(catalogSnapshot, changedCatalogProvider); - const changedProviders = yield* registry.refresh(); - assert.deepStrictEqual( - changedProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.models, - changedCatalogProvider.models, - ); - assert.deepStrictEqual( - changedProviders.find((provider) => provider.instanceId === codexInstanceId), - withBundledCompatibility(codexProvider), - ); - }).pipe(Effect.provide(runtimeServices)); - - assert.strictEqual(yield* Ref.get(codexRefreshCalls), 2); - assert.strictEqual(yield* Ref.get(openCodeRefreshCalls), 2); - }), - ); - - it("persists merged provider snapshots for the providers that were refreshed", () => { - const previousProviders = [ - { - instanceId: ProviderInstanceId.make("cursor"), - driver: ProviderDriverKind.make("cursor"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoning", "Reasoning", [ - { id: "high", label: "High", isDefault: true }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - booleanDescriptor("thinking", "Thinking"), - ], - }), - }, - ], - slashCommands: [], - skills: [], - }, - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - ] as const satisfies ReadonlyArray; - const refreshedCursor = { - ...previousProviders[0], - checkedAt: "2026-04-14T00:01:00.000Z", - models: [], - } satisfies ServerProvider; - - const mergedProviders = ProviderRegistry.mergeProviderSnapshots(previousProviders, [ - refreshedCursor, - ]); - const persistedProviders = ProviderRegistry.selectProvidersByKind( - mergedProviders, - new Set([ProviderDriverKind.make("cursor")]), - ); - - assert.deepStrictEqual(persistedProviders, [ - { - ...refreshedCursor, - models: [...previousProviders[0].models], - }, - ]); - }); - - it.effect("shares one in-flight pass between concurrent untargeted refreshes", () => - Effect.gen(function* () { - const driver = ProviderDriverKind.make("codex"); - const instanceId = ProviderInstanceId.make("codex"); - const provider = { - instanceId, - driver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshCalls = yield* Ref.make(0); - const probeStarted = yield* Deferred.make(); - const releaseProbe = yield* Deferred.make(); - const instances = [ - { - instanceId, - driverKind: driver, - continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: driver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(provider), - refresh: Effect.gen(function* () { - yield* Ref.update(refreshCalls, (count) => count + 1); - yield* Deferred.succeed(probeStarted, undefined); - yield* Deferred.await(releaseProbe); - return provider; - }), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }, - ] satisfies ReadonlyArray; - const instanceRegistryLayer = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, + ] satisfies ReadonlyArray; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, { getInstance: (requestedId) => Effect.succeed(instances.find((instance) => instance.instanceId === requestedId)), @@ -2946,245 +1800,25 @@ it.layer( }), ); - it.effect("keeps consuming registry changes after one sync fails", () => - Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const codexInstanceId = ProviderInstanceId.make("codex"); - const claudeDriver = ProviderDriverKind.make("claudeAgent"); - const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); - const codexProvider = { - instanceId: codexInstanceId, - driver: codexDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-29T10:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const claudeProvider = { - instanceId: claudeInstanceId, - driver: claudeDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-29T10:01:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const makeInstance = (provider: ServerProvider): ProviderInstance => ({ - instanceId: provider.instanceId, - driverKind: provider.driver, - continuationIdentity: { - driverKind: provider.driver, - continuationKey: `${provider.driver}:instance:${provider.instanceId}`, - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: provider.driver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(provider), - refresh: Effect.succeed(provider), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }); - const codexInstance = makeInstance(codexProvider); - const claudeInstance = makeInstance(claudeProvider); - const changes = yield* PubSub.unbounded(); - const instancesRef = yield* Ref.make>([codexInstance]); - const failNextList = yield* Ref.make(false); - const wait = () => Effect.yieldNow; - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Ref.get(instancesRef).pipe( - Effect.map((instances) => - instances.find((instance) => instance.instanceId === instanceId), - ), - ), - listInstances: Effect.gen(function* () { - const shouldFail = yield* Ref.get(failNextList); - if (shouldFail) { - yield* Ref.set(failNextList, false); - return yield* Effect.die(new Error("simulated registry list failure")); - } - return yield* Ref.get(instancesRef); - }), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.fromPubSub(changes), - subscribeChanges: PubSub.subscribe(changes), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistry.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-sync-failure-", - }), - ), - Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [ - withBundledCompatibility(codexProvider), - ]); - - yield* Ref.set(failNextList, true); - yield* PubSub.publish(changes, undefined); - - yield* Ref.set(instancesRef, [codexInstance, claudeInstance]); - yield* PubSub.publish(changes, undefined); - - let providers = yield* registry.getProviders; - for ( - let attempt = 0; - attempt < 50 && !providers.some((provider) => provider.instanceId === claudeInstanceId); - attempt += 1 - ) { - yield* wait(); - providers = yield* registry.getProviders; - } - - assert.deepStrictEqual( - providers.map((provider) => provider.instanceId).toSorted(), - [codexInstanceId, claudeInstanceId].toSorted(), - ); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("propagates real Codex probe failures to the aggregator at boot", () => + it.effect("re-probes when settings change the codex binaryPath", () => Effect.gen(function* () { - const missingBinary = `t3code_codex_missing_`; - const serverSettings = yield* makeMutableServerSettingsService( + const firstMissing = `t3code_codex_first_`; + const secondMissing = `t3code_codex_second_`; + const spawnedCommands: Array = []; + const secondProbeStarted = yield* Deferred.make(); + const releaseSecondProbe = yield* Deferred.make(); + const allowLazySettingsStream = yield* Deferred.make(); + const mutableServerSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { providerInstances: { - ...disabledDefaultSlots("codex", "claudeAgent", "cursor", "grok", "opencode"), - codex_personal: { - driver: "codex", - displayName: "Codex Personal", + ...disabledDefaultSlots("claudeAgent", "cursor", "grok", "opencode"), + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), enabled: true, - config: { - binaryPath: missingBinary, - homePath: `/tmp/${missingBinary}_home`, - }, + config: { binaryPath: firstMissing }, }, - } as unknown as ContractServerSettings["providerInstances"], - }), - ), - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const layerProviderRegistry = ProviderRegistry.layer.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydration.layer), - Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), - Layer.provideMerge( - Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), - ), - Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", - }), - ), - Layer.provideMerge(layerTestHttpClient), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ), - Layer.provideMerge(ProviderLatestVersions.layer), - Layer.provideMerge(McpProviderSessions.layer), - Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(ResetCreditCoordinator.layerTest), - Layer.provideMerge( - OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), - ), - Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - ); - const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( - Scope.provide(scope), - ); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - let providers = yield* registry.getProviders; - for ( - let attempts = 0; - attempts < 50 && - providers.find((provider) => provider.instanceId === "codex_personal")?.status !== - "error"; - attempts += 1 - ) { - yield* Effect.yieldNow; - providers = yield* registry.getProviders; - } - const codexPersonal = providers.find( - (provider) => provider.instanceId === "codex_personal", - ); - assert.notStrictEqual( - codexPersonal, - undefined, - `Expected the aggregator to know about codex_personal; instead saw: ${providers - .map((provider) => provider.instanceId) - .join(", ")}`, - ); - assert.strictEqual( - codexPersonal?.status, - "error", - "Real Codex probe against a missing binary should surface as 'error' in the aggregator", - ); - assert.strictEqual(codexPersonal?.installed, false); - assert.include(codexPersonal?.message, missingBinary); - assert.include(codexPersonal?.message, "Settings → Providers → Codex → Binary path"); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("re-probes when settings change the codex binaryPath", () => - Effect.gen(function* () { - const firstMissing = `t3code_codex_first_`; - const secondMissing = `t3code_codex_second_`; - const spawnedCommands: Array = []; - const secondProbeStarted = yield* Deferred.make(); - const releaseSecondProbe = yield* Deferred.make(); - const allowLazySettingsStream = yield* Deferred.make(); - const mutableServerSettings = yield* makeMutableServerSettingsService( - decodeServerSettings( - deepMerge(encodedDefaultServerSettings, { - providerInstances: { - ...disabledDefaultSlots("claudeAgent", "cursor", "grok", "opencode"), - [ProviderInstanceId.make("codex")]: { - driver: ProviderDriverKind.make("codex"), - enabled: true, - config: { binaryPath: firstMissing }, - }, - }, + }, }), ), ); @@ -3355,164 +1989,210 @@ it.layer( assert.notStrictEqual(ghost, undefined); assert.strictEqual(ghost?.driver, "ghostDriver"); assert.strictEqual(ghost?.availability, "unavailable"); + assert.strictEqual(Object.hasOwn(ghost ?? {}, "setup"), false); assert.match(ghost?.unavailableReason ?? "", /ghostDriver/); }).pipe(Effect.provide(runtimeServices)); }), ); - it.effect( - "keeps Cursor disabled and skips provider probing when settings use their defaults", - () => - Effect.gen(function* () { - const serverSettings = yield* makeMutableServerSettingsService( - decodeServerSettings( - deepMerge(encodedDefaultServerSettings, { - providerInstances: disabledDefaultSlots("codex", "grok"), - }), - ), - ); - let cursorSpawned = false; - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const layerProviderRegistry = ProviderRegistry.layer.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydration.layer), - Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), - Layer.provideMerge( - Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), - ), - Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", - }), - ), - Layer.provideMerge(layerTestHttpClient), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ), - Layer.provideMerge(ProviderLatestVersions.layer), - Layer.provideMerge(McpProviderSessions.layer), - Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(ResetCreditCoordinator.layerTest), - Layer.provideMerge( - OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), - ), - Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge( - layerMockCommandSpawner((command, args) => { - if (command === "cursor-agent") { - cursorSpawned = true; - } - const joined = args.join(" "); - if (joined === "--version") { - return { - stdout: `${command} 1.0.0\n`, - stderr: "", - code: 0, - }; - } - if (joined === "auth status") { - return { - stdout: '{"authenticated":true}\n', - stderr: "", - code: 0, - }; - } - throw new Error(`Unexpected args: ${command} ${joined}`); - }), - ), + const startupSettings = (instanceId?: string) => + decodeServerSettings({ + ...encodedDefaultServerSettings, + providerInstances: { + ...disabledDefaultSlots( + "codex", + "claudeAgent", + "cursor", + "grok", + "opencode", + "antigravity", + "pi", + "muse", + ), + ...(instanceId ? { [instanceId]: { driver: "ghostDriver" } } : {}), + }, + }); + + const hydrateDuringSettingsChange = Effect.fnUntraced(function* (input: { + seed: ContractServerSettings; + saved: ContractServerSettings; + current: ContractServerSettings; + failCurrentRead?: boolean; + }) { + const changes = yield* PubSub.unbounded(); + const consume = yield* Deferred.make(); + const drained: Array> = []; + let readSeed = true; + let failCurrentRead = false; + const settings = yield* makeMutableServerSettingsService(input.current); + const serverSettings = { + ...settings, + getSettings: Effect.suspend(() => { + if (!readSeed) { + readSeed = true; + return PubSub.publish(changes, input.saved).pipe(Effect.as(input.seed)); + } + return failCurrentRead + ? Effect.fail( + new ServerSettingsError({ + settingsPath: "", + operation: "read-secret", + cause: new Error("secret materialization failed"), + }), + ) + : settings.getSettings; + }), + streamChanges: Stream.empty, + subscribeChanges: Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + const done = yield* Deferred.make(); + drained.push(done); + // The terminal marker acknowledges preceding reconciles, including no-op/failed reads. + return Stream.fromEffectRepeat( + Deferred.await(consume).pipe(Effect.andThen(PubSub.take(subscription))), + ).pipe( + Stream.takeWhile((next): next is ContractServerSettings => next !== undefined), + Stream.ensuring(Deferred.succeed(done, undefined)), ); - const runtimeServices = yield* Layer.build( - Layer.mergeAll( - Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), - layerProviderRegistry, + }), + } satisfies ServerSettingsModule.ServerSettingsService["Service"]; + const dependencies = yield* Layer.build( + Layer.mergeAll( + AntigravityInstallation.AntigravityInstallation.layer, + ServerSecretStore.layer, + OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), + ).pipe( + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + ), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-" }), + ), + Layer.provideMerge(layerTestHttpClient), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const providers = yield* registry.getProviders; - const cursorProvider = providers.find( - (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), - ); + ), + Layer.provideMerge(ProviderLatestVersions.layer), + Layer.provideMerge(McpProviderSessions.layer), + Layer.provideMerge(ResetCreditCoordinator.layerTest), + Layer.provideMerge(layerBackgroundPolicyAlwaysRun), + ), + ); + readSeed = false; + const services = yield* Layer.build(ProviderInstanceRegistryHydration.layer).pipe( + Effect.provide(dependencies), + ); + failCurrentRead = input.failCurrentRead ?? false; + yield* PubSub.publish(changes, undefined); + yield* Deferred.succeed(consume, undefined); + yield* Effect.forEach(drained, Deferred.await); + const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry.pipe( + Effect.provide(services), + ); + return (yield* registry.listUnavailable).map( + (provider): string | undefined => provider.instanceId, + ); + }); - assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ - "antigravity", - "claudeAgent", - "codex", - "cursor", - "grok", - "muse", - "opencode", - "pi", - ]); - assert.strictEqual(cursorProvider?.enabled, false); - assert.strictEqual(cursorProvider?.status, "disabled"); - assert.strictEqual(cursorProvider?.message, "Cursor is disabled in T3 Code settings."); - const museProvider = providers.find((provider) => provider.driver === "muse"); - assert.strictEqual(museProvider?.enabled, false); - assert.strictEqual(museProvider?.status, "disabled"); - assert.strictEqual(cursorSpawned, false); - }).pipe(Effect.provide(runtimeServices)); - }), + it.effect("applies a settings change saved while the provider registry starts", () => + Effect.gen(function* () { + const saved = startupSettings("ghost_late"); + const unavailable = yield* hydrateDuringSettingsChange({ + seed: startupSettings(), + saved, + current: saved, + }); + assert.include(unavailable, "ghost_late"); + }), ); - it.effect("skips codex probes entirely when the provider is disabled", () => + it.effect("does not apply a queued settings change older than the registry seed", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( - Effect.provide(layerFailingSpawner("spawn codex ENOENT")), - ); - assert.strictEqual(status.enabled, false); - assert.strictEqual(status.status, "disabled"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.message, "Codex is disabled in T3 Code settings."); + const seed = startupSettings("ghost_current"); + const unavailable = yield* hydrateDuringSettingsChange({ + seed, + saved: startupSettings("ghost_stale"), + current: seed, + }); + assert.include(unavailable, "ghost_current"); + assert.notInclude(unavailable, "ghost_stale"); }), ); + + it.effect( + "retains the newer registry seed when a queued stale change cannot read current settings", + () => + Effect.gen(function* () { + const seed = startupSettings("ghost_current"); + const unavailable = yield* hydrateDuringSettingsChange({ + seed, + saved: startupSettings("ghost_stale"), + current: seed, + failCurrentRead: true, + }); + assert.include(unavailable, "ghost_current"); + assert.notInclude(unavailable, "ghost_stale"); + }), + ); }); describe("checkClaudeProviderStatus", () => { - it.effect("returns ready when claude is installed and authenticated", () => + it.effect("reports a logged-out CLI as unauthenticated", () => Effect.gen(function* () { + // The capability probe resolves for a logged-out CLI, so `tokenSource: + // "none"` is the only thing separating it from an authenticated one. const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, - claudeCapabilities(), + claudeCapabilities({ + tokenSource: "none", + apiKeySource: "none", + apiProvider: "firstParty", + }), + { ANTHROPIC_BASE_URL: "" }, ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.auth.status, "unauthenticated"); }).pipe( Effect.provide( layerMockSpawner((args) => { const joined = args.join(" "); if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; throw new Error(`Unexpected args: ${joined}`); }), ), ), ); - it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () => + it.effect.each([ + { source: "inherited HTTPS endpoint", baseUrl: "https://proxy.example/api", overrides: [] }, + { + source: "instance HTTP endpoint overriding Anthropic", + baseUrl: "https://api.anthropic.com", + overrides: [ + { name: "ANTHROPIC_BASE_URL", value: "http://localhost:8080", sensitive: false }, + ], + }, + ])("keeps Claude ready with an empty first-party account: $source", ({ baseUrl, overrides }) => Effect.gen(function* () { - // Bedrock authenticates via external AWS credentials, so the SDK init - // reports only `apiProvider` with no subscription or token. + const environment = yield* mergeProviderInstanceEnvironment(overrides, { + ANTHROPIC_BASE_URL: baseUrl, + }); const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, - claudeCapabilities({ apiProvider: "bedrock" }), + claudeCapabilities({ + tokenSource: "none", + apiKeySource: "none", + apiProvider: "firstParty", + }), + environment, ); assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "bedrock"); - assert.strictEqual(status.auth.label, "Amazon Bedrock"); + assert.strictEqual(status.auth.type, "customEndpoint"); + assert.strictEqual(status.auth.label, "Custom API endpoint"); }).pipe( Effect.provide( layerMockSpawner((args) => { @@ -3524,10 +2204,19 @@ it.layer( ), ); - it.effect("reports a logged-out CLI as unauthenticated", () => + it.effect.each([ + "https://api.anthropic.com", + "https://api.anthropic.com:443", + "https://api.anthropic.com:8443", + "openrouter.ai/api", + "localhost:8080", + "ftp://proxy.example", + ])("keeps Claude logged out when the instance endpoint is %s", (baseUrl) => Effect.gen(function* () { - // The capability probe resolves for a logged-out CLI, so `tokenSource: - // "none"` is the only thing separating it from an authenticated one. + const environment = yield* mergeProviderInstanceEnvironment( + [{ name: "ANTHROPIC_BASE_URL", value: baseUrl, sensitive: false }], + { ANTHROPIC_BASE_URL: "https://proxy.example/api" }, + ); const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, claudeCapabilities({ @@ -3535,9 +2224,11 @@ it.layer( apiKeySource: "none", apiProvider: "firstParty", }), + environment, ); assert.strictEqual(status.status, "error"); assert.strictEqual(status.auth.status, "unauthenticated"); + assert.strictEqual(status.auth.type, undefined); }).pipe( Effect.provide( layerMockSpawner((args) => { @@ -3574,27 +2265,6 @@ it.layer( ), ); - it.effect("keeps a third-party backend authenticated without any token source", () => - Effect.gen(function* () { - // Bedrock and Vertex authenticate outside the CLI, so the account - // payload is empty by design rather than because nobody logged in. - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ tokenSource: "none", apiProvider: "bedrock" }), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - it.effect("keeps a CLI that says nothing about its account authenticated", () => Effect.gen(function* () { // Profile-authenticated installs report no token source at all, and a @@ -3617,360 +2287,5 @@ it.layer( ), ), ); - - it.effect("returns a display label for claude subscription types", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ subscriptionType: "maxplan" }), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "maxplan"); - assert.strictEqual(status.auth.label, "Claude Max Subscription"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("reads banked resets only for subscription logins", () => - Effect.gen(function* () { - const check = (overrides: Partial) => - checkClaudeProviderStatus( - defaultClaudeSettings, - () => - Effect.succeed({ - email: undefined, - subscriptionType: undefined, - tokenSource: undefined, - apiKeySource: undefined, - apiProvider: undefined, - slashCommands: [], - usage: { rate_limits_available: true, rate_limits: {} }, - ...overrides, - }), - undefined, - undefined, - undefined, - undefined, - () => Effect.succeed({ availableCount: 2 }), - ); - const subscription = yield* check({ subscriptionType: "max" }); - const bedrock = yield* check({ apiProvider: "bedrock" }); - assert.deepStrictEqual(subscription.usageLimits?.resetCredits, { availableCount: 2 }); - assert.strictEqual(bedrock.usageLimits?.resetCredits, undefined); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("does not duplicate Claude in full subscription labels", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "Claude Max Subscription", - }), - ); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "Claude Max Subscription"); - assert.strictEqual(status.auth.label, "Claude Max Subscription"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("does not duplicate Claude in provider-prefixed subscription names", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "Claude Max", - }), - ); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "Claude Max"); - assert.strictEqual(status.auth.label, "Claude Max Subscription"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns claude auth email from initialization result", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ email: "claude@example.com" }), - ); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.email, "claude@example.com"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: - '{"loggedIn":true,"authMethod":"claude.ai","account":{"email":"claude@example.com"}}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("runs Claude status probes with the configured CLAUDE_CONFIG_DIR", () => { - const claudeConfigDir = "/tmp/t3code-claude-home"; - const recorded = recordingMockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }); - - return Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - { - ...defaultClaudeSettings, - homePath: claudeConfigDir, - }, - claudeCapabilities(), - ); - assert.strictEqual(status.status, "ready"); - assert.deepStrictEqual( - recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR), - [(yield* Path.Path).resolve(claudeConfigDir)], - ); - }).pipe(Effect.provide(recorded.layer)); - }); - - it.effect("includes probed claude slash commands in the provider snapshot", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "maxplan", - slashCommands: [ - { - name: "review", - description: "Review a pull request", - input: { hint: "pr-or-branch" }, - }, - ], - }), - ); - - assert.deepStrictEqual(status.slashCommands.slice(1), [ - { - name: "review", - description: "Review a pull request", - input: { hint: "pr-or-branch" }, - }, - ]); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("deduplicates probed claude slash commands by name", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "maxplan", - slashCommands: [ - { - name: "ui", - description: "Explore and refine UI", - }, - { - name: "ui", - input: { hint: "component-or-screen" }, - }, - ], - }), - ); - - assert.deepStrictEqual(status.slashCommands, [ - COMPACT_SLASH_COMMAND, - { - name: "ui", - description: "Explore and refine UI", - input: { hint: "component-or-screen" }, - }, - ]); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns an api key label for claude api key auth", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "apiKey"); - assert.strictEqual(status.auth.label, "Claude API Key"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"api-key"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unavailable when claude is missing", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual(status.message, "Claude Agent CLI (`claude`) was not found on PATH."); - }).pipe(Effect.provide(layerFailingSpawner("spawn claude ENOENT"))), - ); - - it.effect("returns error when version check fails with non-zero exit code", () => { - const secretStderr = "Something went wrong: secret-token-value"; - return Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.message, "Claude Agent CLI is installed but failed to run."); - assert.ok(!(status.message ?? "").includes(secretStderr)); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") - return { - stdout: "", - stderr: secretStderr, - code: 1, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ); - }); - - it.effect("returns warning when the Claude initialization result is unavailable", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - noClaudeCapabilities, - ); - assert.strictEqual(status.status, "warning"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Could not verify Claude authentication status from initialization result.", - ); - - const timedOut = yield* checkClaudeProviderStatus(defaultClaudeSettings, () => - Effect.fail(new Cause.TimeoutError()), - ); - assert.strictEqual(timedOut.status, "warning"); - assert.strictEqual(timedOut.auth.status, "unknown"); - assert.strictEqual( - timedOut.message, - "Timed out after 25s while checking Claude account status.", - ); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":false}\n', - stderr: "", - code: 1, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); }); }); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index bf0571073730..97da44aa93ed 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -21,6 +21,7 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ModelManifest from "./ModelManifest.ts"; import { applyProviderCompatibility } from "./providerCompatibility.ts"; import * as ServerConfig from "../config.ts"; @@ -63,6 +64,12 @@ export class ProviderRegistry extends Context.Service< instanceId: ProviderInstanceId, ) => Effect.Effect>; + /** Only refreshed instances can verify an update; the published list still includes every row. */ + readonly refreshAfterUpdate: ( + provider: ProviderDriverKind, + selectedInstanceId: ProviderInstanceId, + ) => Effect.Effect>; + readonly refreshWorkspaceSnapshot: (input: { readonly instanceId: ProviderInstanceId; readonly cwd: string; @@ -124,15 +131,17 @@ export function upsertProviderWorkspaceSnapshot( cwd: string, scopedSnapshot: ProviderWorkspaceSnapshot, ): ServerProvider { + const previousSnapshot = provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd); + const optionDescriptors = scopedSnapshot.optionDescriptors ?? previousSnapshot?.optionDescriptors; const workspaceSnapshot = { cwd, checkedAt: scopedSnapshot.checkedAt, slashCommands: scopedSnapshot.slashCommandsPending - ? (provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd)?.slashCommands ?? - scopedSnapshot.slashCommands) + ? (previousSnapshot?.slashCommands ?? scopedSnapshot.slashCommands) : scopedSnapshot.slashCommands, ...(scopedSnapshot.slashCommandsPending ? { slashCommandsPending: true } : {}), skills: scopedSnapshot.skills, + ...(optionDescriptors === undefined ? {} : { optionDescriptors }), } satisfies NonNullable[number]; return { ...provider, @@ -188,10 +197,10 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => isAntigravity && provider.status === "warning" && provider.auth.status === "unknown"; const isPendingInitialProbe = provider.enabled && !provider.installed && provider.status === "warning"; - const didInstalledProviderProbeFail = provider.installed && provider.status === "error"; - return ( - isPendingAntigravityAuthentication || isPendingInitialProbe || didInstalledProviderProbeFail - ); + const isIncompleteInstalledProbe = + provider.installed && + (provider.status === "error" || (isCodex && provider.status === "warning")); + return isPendingAntigravityAuthentication || isPendingInitialProbe || isIncompleteInstalledProbe; }; const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean => @@ -287,30 +296,6 @@ export const mergeProviderSnapshot = ( }; }; -export const mergeProviderSnapshots = ( - previousProviders: ReadonlyArray, - nextProviders: ReadonlyArray, -): ReadonlyArray => { - const mergedProviders = new Map( - previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), - ); - - for (const provider of nextProviders) { - mergedProviders.set( - snapshotInstanceKey(provider), - mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider), - ); - } - - return orderProviderSnapshots([...mergedProviders.values()]); -}; - -export const selectProvidersByKind = ( - providers: ReadonlyArray, - providerKinds: ReadonlySet, -): ReadonlyArray => - providers.filter((provider) => providerKinds.has(provider.driver)); - const haveProvidersChanged = ( previousProviders: ReadonlyArray, nextProviders: ReadonlyArray, @@ -357,6 +342,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const instanceRegistry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const manifestService = yield* ModelManifest.ModelManifest; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const serviceScope = yield* Effect.scope; const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -437,7 +423,7 @@ export const layer = Layer.effect( cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), ); const workspaceRefreshesRef = yield* Ref.make< - ReadonlyMap> + ReadonlyMap> >(new Map()); const maintenanceActionStatesRef = yield* Ref.make< ReadonlyMap @@ -452,21 +438,22 @@ export const layer = Layer.effect( liveSubsRef, ).pipe(Effect.map((map) => Array.from(map.values(), buildSnapshotSource))); - const persistProvider = (provider: ServerProvider) => - Effect.gen(function* () { - const key = snapshotInstanceKey(provider); - const filePath = yield* resolveProviderStatusCachePath({ - cacheDir: config.providerStatusCacheDir, - instanceId: key, - }).pipe(Effect.provideService(Path.Path, path)); - const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider; - yield* writeProviderStatusCache({ filePath, provider: machineProvider }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.tapError(Effect.logError), - Effect.ignore, - ); - }); + const persistProvider = Effect.fn("ProviderRegistry.persistProvider")(function* ( + provider: ServerProvider, + ) { + const key = snapshotInstanceKey(provider); + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: key, + }).pipe(Effect.provideService(Path.Path, path)); + const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider; + yield* writeProviderStatusCache({ filePath, provider: machineProvider }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.tapError(Effect.logError), + Effect.ignore, + ); + }); const applyProviderUpdateState = Effect.fn("applyProviderUpdateState")(function* ( provider: ServerProvider, @@ -685,6 +672,70 @@ export const layer = Layer.effect( return yield* refreshOneSource(providerSource); }); + const refreshAfterUpdate = Effect.fn("refreshAfterUpdate")(function* ( + provider: ProviderDriverKind, + selectedInstanceId: ProviderInstanceId, + ) { + yield* manifestService.forceRefresh; + const instances = (yield* instanceRegistry.listInstances).filter( + (instance) => + instance.driverKind === provider && + (instance.enabled || instance.instanceId === selectedInstanceId), + ); + const failedInstances = new Set(); + const recoverInstanceFailure = Effect.fn("recoverPostUpdateRefreshFailure")(function* ( + instanceId: ProviderInstanceId, + cause: Cause.Cause, + ) { + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.interrupt; + } + yield* Effect.logError("provider post-update refresh failed", { + instanceId, + cause: Cause.pretty(cause), + }); + failedInstances.add(instanceId); + // A cached ready snapshot cannot verify a successful update. + return yield* updateProviders((providers) => + providers.map((candidate) => + candidate.instanceId === instanceId + ? { ...candidate, status: "error", message: "Provider refresh failed after update." } + : candidate, + ), + ); + }); + // Accounts can share an installation, but discovery must use each + // instance's own executable, environment, and credentials. + yield* Effect.forEach( + instances, + (instance) => + Effect.gen(function* () { + yield* instance.invalidateCaches ?? Effect.void; + const maintenance = yield* instance.snapshot.resolveMaintenance({ fresh: true }); + if (maintenance.packageName) { + yield* latestVersions.invalidate(maintenance.packageName); + } + }).pipe(Effect.catchCause((cause) => recoverInstanceFailure(instance.instanceId, cause))), + { concurrency: "unbounded", discard: true }, + ); + yield* Effect.forEach( + instances.filter((instance) => !failedInstances.has(instance.instanceId)), + (instance) => + refreshOneSource(buildSnapshotSource(instance)).pipe( + Effect.catchCause((cause) => recoverInstanceFailure(instance.instanceId, cause)), + ), + { concurrency: "unbounded", discard: true }, + ); + const refreshedInstanceIds = new Set( + instances + .filter((instance) => !failedInstances.has(instance.instanceId)) + .map((instance) => instance.instanceId), + ); + return (yield* Ref.get(providersRef)).filter((candidate) => + refreshedInstanceIds.has(candidate.instanceId), + ); + }); + const getProviderMaintenanceCapabilitiesForInstance = Effect.fn( "getProviderMaintenanceCapabilitiesForInstance", )(function* ( @@ -897,14 +948,15 @@ export const layer = Layer.effect( const scannedAt = DateTime.formatIso(now); const instance = yield* instanceRegistry.getInstance(input.instanceId); if (!instance?.snapshotForCwd) return providers; + const token = Symbol(); const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { const current = refreshes.get(instance); - if (current?.has(input.cwd)) return [false, refreshes] as const; + if (!input.fresh && current?.has(input.cwd)) return [false, refreshes] as const; const next = new Map(refreshes); - next.set(instance, new Set(current).add(input.cwd)); + next.set(instance, new Map(current).set(input.cwd, token)); return [true, next] as const; }); - if (!claimed && !input.fresh) return yield* Ref.get(providersRef); + if (!claimed) return yield* Ref.get(providersRef); const refreshMachineSnapshot = input.fresh ? (instance.invalidateCaches ?? Effect.void).pipe( Effect.andThen(refreshInstance(input.instanceId)), @@ -918,8 +970,12 @@ export const layer = Layer.effect( instanceRegistry.getInstance(input.instanceId).pipe( Effect.flatMap((currentInstance) => { if (currentInstance !== instance) return Ref.get(providersRef); - return updateProviders((currentProviders) => - currentProviders.map((candidate) => + return updateProviders((currentProviders) => { + // Read ownership inside the update so a newer claim cannot race publication. + if (Ref.getUnsafe(workspaceRefreshesRef).get(instance)?.get(input.cwd) !== token) { + return currentProviders; + } + return currentProviders.map((candidate) => candidate.instanceId === input.instanceId && Equal.equals(workspaceSnapshotOf(candidate), scannedFrom) ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, { @@ -932,22 +988,22 @@ export const layer = Layer.effect( scopedSnapshot.slashCommandsPending ?? scopedSnapshot.status === "error", }) : candidate, - ), - ); + ); + }); }), ), ), Effect.ensuring( - claimed - ? Ref.update(workspaceRefreshesRef, (refreshes) => { - const next = new Map(refreshes); - const current = new Set(next.get(instance)); - current.delete(input.cwd); - if (current.size) next.set(instance, current); - else next.delete(instance); - return next; - }) - : Effect.void, + Ref.update(workspaceRefreshesRef, (refreshes) => { + const current = refreshes.get(instance); + if (current?.get(input.cwd) !== token) return refreshes; + const next = new Map(refreshes); + const nextCwds = new Map(current); + nextCwds.delete(input.cwd); + if (nextCwds.size) next.set(instance, nextCwds); + else next.delete(instance); + return next; + }), ), ); }); @@ -960,6 +1016,7 @@ export const layer = Layer.effect( refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), refreshWorkspaceSnapshot: (input) => refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshAfterUpdate, getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, get streamChanges() { diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 4d1de51cc1b7..36fee6411d76 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -5,20 +5,18 @@ import * as NodeURL from "node:url"; import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; -import * as Option from "effect/Option"; import * as Scope from "effect/Scope"; import * as Result from "effect/Result"; -import * as TestClock from "effect/testing/TestClock"; import * as Stream from "effect/Stream"; import { describe, expect } from "vite-plus/test"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; -import type * as EffectAcpProtocol from "effect-acp/protocol"; import * as EffectAcpErrors from "effect-acp/errors"; const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -108,42 +106,6 @@ describe("AcpSessionRuntime", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("awaits native resume instead of using the load replay idle fallback", () => - Effect.gen(function* () { - const resumeStarted = yield* Deferred.make(); - const requestMethods: Array = []; - const runtime = yield* AcpSessionRuntime.make({ - ...mockRuntimeOptions, - spawn: { - ...mockRuntimeOptions.spawn, - env: { T3_ACP_WAIT_FOR_RESUME_RELEASE: "1", T3_ACP_SESSION_LIFECYCLE: "1" }, - }, - resumeSessionId: "mock-session-1", - resumeMethod: "resume", - sessionLoadReplayIdleGap: "1 second", - requestLogger: (event) => - Effect.sync(() => { - if (event.status === "started") requestMethods.push(event.method); - }), - }); - yield* runtime.handleSessionUpdate((notification) => - notification.update.sessionUpdate === "user_message_chunk" - ? Deferred.succeed(resumeStarted, undefined).pipe(Effect.asVoid) - : Effect.void, - ); - const startup = yield* runtime.start().pipe(Effect.forkChild); - yield* Deferred.await(resumeStarted); - yield* TestClock.adjust("3 seconds"); - expect(startup.pollUnsafe()).toBeUndefined(); - yield* runtime.request("_test/release-resume", {}); - const started = yield* Fiber.join(startup); - - expect(started.sessionSetupResult._meta).toEqual({ nativeResume: true }); - expect(requestMethods).toContain("session/resume"); - expect(requestMethods).not.toContain("session/load"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("waits for native cancellation and drains final updates before another prompt", () => Effect.gen(function* () { const toolStarted = yield* Deferred.make(); @@ -229,61 +191,6 @@ describe("AcpSessionRuntime", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("retires a process when native cancellation times out", () => - Effect.gen(function* () { - const toolStarted = yield* Deferred.make(); - const cancelReceived = yield* Deferred.make(); - const runtime = yield* AcpSessionRuntime.make({ - ...mockRuntimeOptions, - spawn: { - ...mockRuntimeOptions.spawn, - env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" }, - }, - cancelBehavior: "wait-for-prompt", - cancelTimeout: "1 second", - }); - yield* runtime.getEvents().pipe( - Stream.runForEach((event) => { - if (event._tag === "EventStreamBarrier") { - return Deferred.succeed(event.acknowledge, undefined); - } - if (event._tag === "ToolCallUpdated") { - return Deferred.succeed(toolStarted, undefined); - } - if (event._tag === "ThoughtDelta") { - return Deferred.succeed(cancelReceived, undefined); - } - return Effect.void; - }), - Effect.forkChild, - ); - yield* runtime.start(); - const prompt = yield* runtime - .prompt({ - prompt: [{ type: "text", text: "first" }], - }) - .pipe(Effect.forkChild); - yield* Deferred.await(toolStarted); - const cancellation = yield* runtime.cancel.pipe(Effect.forkChild); - yield* Deferred.await(cancelReceived); - yield* TestClock.adjust("2 seconds"); - - const error = yield* Fiber.join(cancellation).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "AcpTransportError", - method: "session/cancel", - }); - expect(Exit.isFailure(yield* Fiber.await(prompt))).toBe(true); - expect( - yield* runtime - .prompt({ - prompt: [{ type: "text", text: "must not run" }], - }) - .pipe(Effect.flip), - ).toBe(error); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("reports an idle child exit and rejects later prompts", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions); @@ -376,7 +283,19 @@ describe("AcpSessionRuntime", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("attaches child stderr when the ACP process exits before initialize", () => + it.effect.each([ + { name: "exit code", terminate: "process.exit(1)", code: 1, signal: undefined }, + ...(HostProcess.Platform.defaultValue() === "win32" + ? [] + : [ + { + name: "signal", + terminate: "process.kill(process.pid, 'SIGTERM')", + code: undefined, + signal: "SIGTERM", + }, + ]), + ])("retains the $name and sanitized stderr when ACP exits before initialize", (testCase) => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.make({ ...mockRuntimeOptions, @@ -384,15 +303,31 @@ describe("AcpSessionRuntime", () => { command: process.execPath, args: [ "-e", - "process.stderr.write(\"Invalid project config at /tmp/project/.cursor/cli.json: schema validation failed. Unrecognized key(s): 'approvalMode', 'sandbox'\\n\"); process.exit(1);", + `process.stderr.write("Invalid project config: Unrecognized key in cli.json\\nAuthorization: Bearer secret-token-value\\n", () => { ${testCase.terminate}; });`, ], }, }); const error = yield* runtime.start().pipe(Effect.flip); - expect(error._tag).toBe("AcpProcessExitedError"); - expect(error.message).toContain("cli.json"); - expect(error.message).toContain("Unrecognized key"); - expect(error.message).not.toContain("ACP process exited with code 1\nACP process exited"); + assert.instanceOf(error, EffectAcpErrors.AcpProcessExitedError); + expect(error.code).toBe(testCase.code); + expect(error.signal).toBe(testCase.signal); + expect(error).toMatchObject({ + pid: expect.any(Number), + stderr: + "Invalid project config: Unrecognized key in cli.json\nAuthorization: Bearer [redacted]", + }); + expect(error.message).toBe( + `${testCase.signal ? "ACP process exited after signal SIGTERM" : "ACP process exited with code 1"}\nInvalid project config: Unrecognized key in cli.json\nAuthorization: Bearer [redacted]`, + ); + if (testCase.signal) { + expect(error.cause).toMatchObject({ + reason: { + module: "ChildProcess", + method: "exitCode", + cause: new Error("Process interrupted due to receipt of signal: 'SIGTERM'"), + }, + }); + } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); @@ -452,47 +387,6 @@ describe("AcpSessionRuntime", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("bounds native cancellation when its event consumer is absent", () => - Effect.gen(function* () { - const toolStarted = yield* Deferred.make(); - const cancelReceived = yield* Deferred.make(); - const runtime = yield* AcpSessionRuntime.make({ - ...mockRuntimeOptions, - spawn: { - ...mockRuntimeOptions.spawn, - env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" }, - }, - cancelBehavior: "wait-for-prompt", - cancelTimeout: "1 second", - }); - yield* runtime.handleSessionUpdate((notification) => { - if (notification.update.sessionUpdate === "tool_call_update") { - return Deferred.succeed(toolStarted, undefined).pipe(Effect.asVoid); - } - if (notification.update.sessionUpdate === "agent_thought_chunk") { - return Deferred.succeed(cancelReceived, undefined).pipe(Effect.asVoid); - } - return Effect.void; - }); - yield* runtime.start(); - const prompt = yield* runtime - .prompt({ - prompt: [{ type: "text", text: "first" }], - }) - .pipe(Effect.forkChild); - yield* Deferred.await(toolStarted); - const cancellation = yield* runtime.cancel.pipe(Effect.forkChild); - yield* Deferred.await(cancelReceived); - yield* runtime.request("_test/finish-cancel", {}); - expect(yield* Fiber.join(prompt)).toMatchObject({ stopReason: "cancelled" }); - yield* TestClock.adjust("2 seconds"); - expect(yield* Fiber.join(cancellation).pipe(Effect.flip)).toMatchObject({ - _tag: "AcpTransportError", - method: "session/cancel", - }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("does not restore ambient variables to a sanitized child environment", () => Effect.gen(function* () { yield* Effect.acquireRelease( @@ -535,49 +429,6 @@ describe("AcpSessionRuntime", () => { expect(AcpSessionRuntime.selectAcpAgentAuthMethod(methods, "missing")).toBeUndefined(); }); - it.effect("merges custom initialize client capabilities into the ACP handshake", () => { - const requestEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - const initializeStarted = requestEvents.find( - (event) => event.method === "initialize" && event.status === "started", - ); - expect(initializeStarted?.payload).toMatchObject({ - protocolVersion: 2, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - _meta: { parameterizedModelPicker: true }, - }, - }); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - }, - cwd: process.cwd(), - clientCapabilities: { - _meta: { - parameterizedModelPicker: true, - }, - }, - clientInfo: { name: "t3-test", version: "0.0.0" }, - authMethodId: "test", - requestLogger: (event) => - Effect.sync(() => { - requestEvents.push(event); - }), - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - it.effect("prefers MCP-over-ACP only when the agent advertises that transport", () => { const requestEvents: Array = []; return Effect.gen(function* () { @@ -614,41 +465,6 @@ describe("AcpSessionRuntime", () => { ); }); - it.effect("falls back to stdio MCP when the agent does not advertise MCP-over-ACP", () => { - const requestEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - const created = requestEvents.find( - (event) => event.method === "session/new" && event.status === "started", - ); - expect(created?.payload).toMatchObject({ - mcpServers: [{ type: "stdio", name: "t3-code", command: "/usr/bin/node", args: [] }], - }); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - authMethodId: "test", - mcpServers: [{ type: "stdio", name: "t3-code", command: "/usr/bin/node", args: [] }], - acpMcpServers: [{ type: "acp", name: "t3-code", serverId: "t3-code" }], - requestLogger: (event) => - Effect.sync(() => { - requestEvents.push(event); - }), - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - it.effect("supports v2 session deletion and secret-safe provider management", () => { const requestEvents: Array = []; return Effect.gen(function* () { @@ -701,37 +517,6 @@ describe("AcpSessionRuntime", () => { ); }); - it.effect("does not authenticate when an advertised method is not required", () => { - const requestEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - expect( - requestEvents.filter((event) => event.status === "started").map((event) => event.method), - ).toEqual(["initialize", "session/new"]); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "test", - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - env: { T3_ACP_AUTH_METHOD_ID: "test" }, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - requestLogger: (event) => - Effect.sync(() => { - requestEvents.push(event); - }), - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - it.effect("authenticates explicitly typed v2 agent methods and retries session creation", () => { const requestEvents: Array = []; return Effect.gen(function* () { @@ -956,100 +741,6 @@ describe("AcpSessionRuntime", () => { ), ); - it.effect("supports successive standard ACP prompts", () => - Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - const firstPromptResult = yield* runtime.prompt({ - prompt: [{ type: "text", text: "first" }], - }); - const secondPromptResult = yield* runtime.prompt({ - prompt: [{ type: "text", text: "second" }], - }); - - expect(firstPromptResult).toMatchObject({ stopReason: "end_turn" }); - expect(secondPromptResult).toMatchObject({ stopReason: "end_turn" }); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - authMethodId: "test", - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ), - ); - - it.effect("releases a fully silent prompt and forwards configured cancel metadata", () => { - const protocolEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - const promptFiber = yield* runtime - .prompt({ - prompt: [{ type: "text", text: "hang forever" }], - }) - .pipe(Effect.forkChild({ startImmediately: true })); - - yield* TestClock.adjust("500 millis"); - yield* runtime.cancel; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - expect( - protocolEvents.some( - (event) => - event.direction === "outgoing" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"method":"session/cancel"') && - event.payload.includes('"cancelTrigger":"ctrl_c"'), - ), - ).toBe(true); - - const firstPromptResult = yield* Fiber.join(promptFiber); - expect(firstPromptResult).toMatchObject({ stopReason: "cancelled" }); - - const secondPromptResult = yield* runtime.prompt({ - prompt: [{ type: "text", text: "second" }], - }); - expect(secondPromptResult).toMatchObject({ stopReason: "end_turn" }); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - env: { - T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", - }, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - authMethodId: "test", - cancelMeta: { cancelTrigger: "ctrl_c" }, - protocolLogging: { - logOutgoing: true, - logger: (event) => - Effect.sync(() => { - protocolEvents.push(event); - }), - }, - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - it.effect("segments assistant text around ACP tool calls", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; @@ -1204,58 +895,6 @@ describe("AcpSessionRuntime", () => { ), ); - it.effect("logs ACP requests from the shared runtime", () => { - const requestEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - yield* runtime.setModel("composer-2"); - yield* runtime.prompt({ - prompt: [{ type: "text", text: "hi" }], - }); - - expect( - requestEvents.some( - (event) => event.method === "session/set_config_option" && event.status === "started", - ), - ).toBe(true); - expect( - requestEvents.some( - (event) => event.method === "session/set_config_option" && event.status === "succeeded", - ), - ).toBe(true); - expect( - requestEvents.some( - (event) => event.method === "session/prompt" && event.status === "started", - ), - ).toBe(true); - expect( - requestEvents.some( - (event) => event.method === "session/prompt" && event.status === "succeeded", - ), - ).toBe(true); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "test", - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - requestLogger: (event) => - Effect.sync(() => { - requestEvents.push(event); - }), - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - it.effect("supports negotiated ACP session list, fork, resume, and close methods", () => { const requestEvents: Array = []; return Effect.gen(function* () { @@ -1321,37 +960,6 @@ describe("AcpSessionRuntime", () => { ); }); - it.effect("lists sessions without creating a disposable session", () => { - const requestEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - const listed = yield* runtime.listSessions(); - - expect(listed.sessions).toHaveLength(1); - expect( - requestEvents.filter((event) => event.status === "started").map((event) => event.method), - ).toEqual(["initialize", "session/list"]); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - env: { T3_ACP_SESSION_LIFECYCLE: "1" }, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - requestLogger: (event) => - Effect.sync(() => { - requestEvents.push(event); - }), - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - it.effect("rejects unauthenticated ACP session lifecycle requests", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; @@ -1401,88 +1009,7 @@ describe("AcpSessionRuntime", () => { ), ); - it.effect("skips no-op session config writes when the requested value is already active", () => { - const requestEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - yield* runtime.setConfigOption("model", "default"); - yield* runtime.setMode("ask"); - - expect( - requestEvents.some( - (event) => event.method === "session/set_config_option" && event.status === "started", - ), - ).toBe(false); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "test", - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - requestLogger: (event) => - Effect.sync(() => { - requestEvents.push(event); - }), - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - - it.effect("emits low-level ACP protocol logs for raw and decoded messages", () => { - const protocolEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - yield* runtime.prompt({ - prompt: [{ type: "text", text: "hi" }], - }); - - expect( - protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "raw"), - ).toBe(true); - expect( - protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "decoded"), - ).toBe(true); - expect( - protocolEvents.some((event) => event.direction === "incoming" && event.stage === "raw"), - ).toBe(true); - expect( - protocolEvents.some((event) => event.direction === "incoming" && event.stage === "decoded"), - ).toBe(true); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "test", - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - protocolLogging: { - logIncoming: true, - logOutgoing: true, - logger: (event) => - Effect.sync(() => { - protocolEvents.push(event); - }), - }, - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - + // regression: #3156 harden ACP resume with replay-idle load readiness it.effect("fails session startup when session/load returns an error", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; @@ -1578,129 +1105,6 @@ describe("AcpSessionRuntime", () => { ), ); - it.effect("keeps active-session updates while loading a different session", () => - Effect.gen(function* () { - const loadStarted = yield* Deferred.make(); - const runtime = yield* AcpSessionRuntime.make({ - authMethodId: "test", - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - env: { - T3_ACP_DELAY_LOAD_SESSION_AFTER_REPLAY: "1", - T3_ACP_LOAD_SESSION_DELAY_MS: "250", - }, - }, - cwd: process.cwd(), - sessionLoadTimeout: "2 seconds", - clientInfo: { name: "t3-test", version: "0.0.0" }, - requestLogger: (event) => - event.method === "session/load" && event.status === "started" - ? Deferred.succeed(loadStarted, undefined).pipe(Effect.asVoid) - : Effect.void, - }); - yield* runtime.start(); - - const eventsFiber = yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4)).pipe( - Effect.forkChild({ startImmediately: true }), - ); - const loadFiber = yield* runtime - .loadSession("mock-session-2") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(loadStarted); - - yield* runtime.prompt({ - prompt: [{ type: "text", text: "active session prompt" }], - }); - const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("2 seconds"))); - expect(events.map((event) => event._tag)).toEqual([ - "PlanUpdated", - "AssistantItemStarted", - "ContentDelta", - "AssistantItemCompleted", - ]); - expect( - events.some((event) => event._tag === "ContentDelta" && event.text === "hello from mock"), - ).toBe(true); - - const loaded = yield* Fiber.join(loadFiber).pipe(Effect.timeout("2 seconds")); - expect(loaded.sessionId).toBe("mock-session-2"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), TestClock.withLive), - ); - - it.effect("completes session/load after replay becomes idle while its RPC stays pending", () => - Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - const started = yield* runtime.start().pipe(Effect.timeout("2 seconds")); - - expect(started.sessionId).toBe("mock-session-1"); - expect(started.sessionSetupResult._meta).toMatchObject({ - t3SessionLoadReady: "replay_idle", - }); - - const unexpectedReplayEvent = yield* Stream.runHead(runtime.getEvents()).pipe( - Effect.timeoutOption("100 millis"), - ); - expect(Option.isNone(unexpectedReplayEvent)).toBe(true); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "test", - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - env: { - T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY: "1", - T3_ACP_LOAD_SESSION_DELAY_MS: "10000", - }, - }, - cwd: process.cwd(), - resumeSessionId: "mock-session-1", - sessionLoadReplayIdleGap: "50 millis", - sessionLoadTimeout: "1 second", - clientInfo: { name: "t3-test", version: "0.0.0" }, - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - TestClock.withLive, - ), - ); - - it.effect("completes ad-hoc loadSession after replay becomes idle while RPC stays pending", () => - Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - const loaded = yield* runtime.loadSession("mock-session-1").pipe(Effect.timeout("2 seconds")); - - expect(loaded.sessionId).toBe("mock-session-1"); - expect(loaded.sessionSetupResult._meta).toMatchObject({ - t3SessionLoadReady: "replay_idle", - }); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "test", - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - env: { - T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY: "1", - T3_ACP_LOAD_SESSION_DELAY_MS: "10000", - }, - }, - cwd: process.cwd(), - sessionLoadReplayIdleGap: "50 millis", - sessionLoadTimeout: "1 second", - clientInfo: { name: "t3-test", version: "0.0.0" }, - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - TestClock.withLive, - ), - ); - it.effect("rejects invalid config option values before sending session/set_config_option", () => { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-")); const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts index cc911f2c60d0..338588662935 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts @@ -2,21 +2,14 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; import { describe, expect, it, vi } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as Scope from "effect/Scope"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; -const threadSpawnHelperSource = NodeURL.fileURLToPath( - new URL("../../../scripts/acp-thread-spawn-helper.c", import.meta.url), -); - const identity = ( pid: number, ppid: number, @@ -63,15 +56,6 @@ function makeController(input: { const server = () => identity(process.pid, 1, process.pid, process.pid, "server"); -function processExists(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - describe("terminatePosixOwnedProcessTree", () => { it("parses unified cgroup paths and escaped cgroup2 mountinfo", () => { expect(AcpSessionRuntime.parseUnifiedCgroupPath("0::/user.slice/app.scope\n")).toBe( @@ -321,171 +305,6 @@ describe("terminatePosixOwnedProcessTree", () => { } }); - it.live("kills a post-TERM detached double fork without touching an unrelated sentinel", () => - Effect.gen(function* () { - if ((yield* HostProcess.Platform) !== "linux") return; - const controller = AcpSessionRuntime.makeLinuxCgroupController(); - const lease = controller.create(); - if (lease === undefined) return; - expect( - AcpSessionRuntime.parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8")), - ).not.toBe(lease.relativePath); - const scratchRoot = NodePath.join(process.cwd(), "tmp"); - NodeFS.mkdirSync(scratchRoot, { recursive: true }); - const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-kill-")); - const readyPath = NodePath.join(scratch, "ready"); - const childPath = NodePath.join(scratch, "child"); - const childProgram = "setInterval(() => {}, 1000);"; - const launcherProgram = [ - 'const fs = require("node:fs");', - 'const cp = require("node:child_process");', - "const child = cp.spawn(process.execPath, ['-e', process.argv[2]], { detached: true, stdio: 'ignore' });", - "fs.writeFileSync(process.argv[1], String(child.pid));", - "child.unref();", - ].join("\n"); - const providerProgram = [ - 'const fs = require("node:fs");', - 'const cp = require("node:child_process");', - "fs.writeFileSync(process.argv[1], String(process.pid));", - "process.on('SIGTERM', () => {", - " const launcher = cp.spawn(process.execPath, ['-e', process.argv[3], process.argv[2], process.argv[4]], { detached: true, stdio: 'ignore' });", - " launcher.unref();", - " process.exit(0);", - "});", - "setInterval(() => {}, 1000);", - ].join("\n"); - const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, process.execPath, [ - "-e", - providerProgram, - readyPath, - childPath, - launcherProgram, - childProgram, - ]); - const provider = NodeChildProcess.spawn(wrapped.command, wrapped.args, { - detached: true, - env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", T3_ACP_CGROUP_WRAPPER: "1" }, - stdio: "ignore", - }); - provider.unref(); - const sentinel = NodeChildProcess.spawn(process.execPath, ["-e", childProgram], { - detached: true, - stdio: "ignore", - }); - sentinel.unref(); - let detachedChildPid: number | undefined; - try { - yield* Effect.gen(function* () { - while (!NodeFS.existsSync(readyPath)) yield* Effect.sleep("10 millis"); - }).pipe(Effect.timeout("2 seconds")); - process.kill(provider.pid!, "SIGTERM"); - detachedChildPid = yield* Effect.gen(function* () { - while (true) { - try { - const pid = Number(NodeFS.readFileSync(childPath, "utf8")); - if (Number.isSafeInteger(pid) && pid > 1) return pid; - } catch {} - yield* Effect.sleep("10 millis"); - } - }).pipe(Effect.timeout("2 seconds")); - expect(processExists(detachedChildPid)).toBe(true); - yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease); - expect(processExists(sentinel.pid!)).toBe(true); - yield* Effect.gen(function* () { - while (processExists(detachedChildPid!)) yield* Effect.sleep("10 millis"); - }).pipe(Effect.timeout("2 seconds")); - expect(NodeFS.existsSync(lease.path)).toBe(false); - - const replacement = controller.create(); - expect(replacement?.path).not.toBe(lease.path); - if (replacement !== undefined) { - NodeFS.mkdirSync(NodePath.join(replacement.path, "nested")); - yield* AcpSessionRuntime.terminateLinuxCgroupLease(replacement); - expect(NodeFS.existsSync(replacement.path)).toBe(false); - } - } finally { - yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease).pipe(Effect.ignore); - try { - process.kill(-provider.pid!, "SIGKILL"); - } catch {} - try { - process.kill(-sentinel.pid!, "SIGKILL"); - } catch {} - if (detachedChildPid !== undefined && processExists(detachedChildPid)) { - try { - process.kill(detachedChildPid, "SIGKILL"); - } catch {} - } - NodeFS.rmSync(scratch, { recursive: true, force: true }); - } - }), - ); - - it.live("re-kills repopulated cgroups and verifies root removal after retryable errors", () => - Effect.gen(function* () { - let exists = true; - let killCalls = 0; - let populated = false; - let removeCalls = 0; - const lease: AcpSessionRuntime.AcpLinuxCgroupLease = { - contains: () => false, - exists: () => exists, - path: "/test/t3-acp-repopulation", - relativePath: "/test/t3-acp-repopulation", - kill: () => { - killCalls += 1; - populated = false; - }, - populated: () => populated, - remove: () => { - removeCalls += 1; - if (removeCalls === 1) { - populated = true; - const error = new Error("repopulated") as NodeJS.ErrnoException; - error.code = "EBUSY"; - throw error; - } - if (removeCalls === 2) { - const error = new Error("nested path vanished") as NodeJS.ErrnoException; - error.code = "ENOENT"; - throw error; - } - exists = false; - }, - }; - - yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease); - expect(killCalls).toBeGreaterThanOrEqual(3); - expect(removeCalls).toBe(3); - expect(exists).toBe(false); - yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease); - expect(removeCalls).toBe(3); - }), - ); - - it.live("reports persistent retryable removal failures as removal failures", () => - Effect.gen(function* () { - const removeError = Object.assign(new Error("cgroup remains busy"), { - code: "EBUSY", - }); - const lease: AcpSessionRuntime.AcpLinuxCgroupLease = { - contains: () => false, - exists: () => true, - path: "/test/t3-acp-busy", - relativePath: "/test/t3-acp-busy", - kill: () => undefined, - populated: () => false, - remove: () => { - throw removeError; - }, - }; - - const error = yield* Effect.flip(AcpSessionRuntime.terminateLinuxCgroupLease(lease)); - expect(error.detail).toBe("Failed to remove ACP cgroup /test/t3-acp-busy"); - expect(error.cause).toBe(removeError); - }), - ); - it("sends TERM only to the exact captured root while it remains in the child cgroup", () => { const fixture = makeController({ processes: [server(), identity(100, process.pid, 100, 100, "owned")], @@ -528,53 +347,6 @@ describe("terminatePosixOwnedProcessTree", () => { expect(migrated.signals).toEqual([]); }); - it.live("finds a child forked by a non-leader pthread", () => - Effect.gen(function* () { - if ((yield* HostProcess.Platform) !== "linux") return; - const scratchRoot = NodePath.join(process.cwd(), "tmp"); - NodeFS.mkdirSync(scratchRoot, { recursive: true }); - const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-pthread-spawn-")); - const binary = NodePath.join(scratch, "helper"); - const pidPath = NodePath.join(scratch, "pids"); - expect(NodeFS.statSync(threadSpawnHelperSource).isFile()).toBe(true); - const compile = NodeChildProcess.spawnSync( - "cc", - ["-pthread", threadSpawnHelperSource, "-o", binary], - { - encoding: "utf8", - }, - ); - if ((compile.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") { - NodeFS.rmSync(scratch, { recursive: true, force: true }); - return; - } - expect(compile.status, compile.stderr).toBe(0); - const helper = NodeChildProcess.spawn(binary, [pidPath], { detached: true, stdio: "ignore" }); - helper.unref(); - try { - const published = yield* Effect.gen(function* () { - while (true) { - try { - const ids = NodeFS.readFileSync(pidPath, "utf8").trim().split(/\s+/).map(Number); - if (ids.length === 2 && ids.every(Number.isSafeInteger)) return ids; - } catch {} - yield* Effect.sleep("10 millis"); - } - }).pipe(Effect.timeout("2 seconds")); - const [workerTid, childPid] = published; - expect(workerTid).not.toBe(helper.pid); - expect( - AcpSessionRuntime.makePosixProcessTreeController("linux").childrenOf(helper.pid!), - ).toContainEqual(expect.objectContaining({ pid: childPid, ppid: helper.pid })); - } finally { - try { - process.kill(-helper.pid!, "SIGKILL"); - } catch {} - NodeFS.rmSync(scratch, { recursive: true, force: true }); - } - }), - ); - it.live("keeps ownership across a fork-before-exec executable transition", () => Effect.gen(function* () { const beforeExec = { ...identity(110, 100, 110, 110), executable: "/provider/fork" }; @@ -742,73 +514,6 @@ describe("terminatePosixOwnedProcessTree", () => { }), ); - it.live("polls only known descendants and stops with its scope", () => - Effect.gen(function* () { - let childrenCalls = 0; - let identityCalls = 0; - let snapshotCalls = 0; - const fixture = makeController({ - processes: [server(), identity(100, process.pid, 100, 100), identity(110, 100, 110, 110)], - }); - const controller: AcpSessionRuntime.AcpPosixProcessTreeController = { - ...fixture.controller, - childPidsOf: (pid) => { - childrenCalls += 1; - return fixture.controller.childPidsOf(pid); - }, - identity: (pid) => { - identityCalls += 1; - return fixture.controller.identity(pid); - }, - snapshot: () => { - snapshotCalls += 1; - return fixture.controller.snapshot(); - }, - }; - const scope = yield* Scope.make(); - const frontier = new Map(); - const childQueues = new Map>(); - let ledgerValuesCalls = 0; - const ledger = new (class extends Map { - override values(): MapIterator { - ledgerValuesCalls += 1; - return super.values(); - } - })(); - const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; - for (let index = 0; index < 5_000; index += 1) { - const tombstone = identity(10_000 + index, 1, 10_000 + index, 10_000 + index); - ledger.set(`${tombstone.pid}:${tombstone.startTime}`, { - ...tombstone, - parentExecutable: undefined, - parentStartTime: "", - }); - } - yield* AcpSessionRuntime.observePosixOwnershipLedgerContinuously({ - childQueues, - controller, - frontier, - ledger, - root, - rootPid: 100, - }).pipe(Effect.forkIn(scope)); - const timerStartedAt = yield* Clock.currentTimeMillis; - yield* Effect.sleep(0); - const timerDelay = (yield* Clock.currentTimeMillis) - timerStartedAt; - yield* Effect.sleep("80 millis"); - - expect(timerDelay).toBeLessThan(250); - expect(ledgerValuesCalls).toBe(0); - expect(snapshotCalls).toBe(0); - expect(childrenCalls).toBeGreaterThan(0); - expect(childrenCalls).toBeLessThanOrEqual(12); - const callsAtClose = identityCalls + childrenCalls; - yield* Scope.close(scope, Exit.void); - yield* Effect.sleep("60 millis"); - expect(identityCalls + childrenCalls).toBe(callsAtClose); - }), - ); - it.live("terminates nested owned groups bottom-up and catches a TERM fork race", () => Effect.gen(function* () { let forked = false; @@ -988,36 +693,6 @@ describe("terminatePosixOwnedProcessTree", () => { expect(admitted?.startTime).toBe("live-child"); }); - it("admits a live child when a stale ledger entry still occupies the reused PID", () => { - const staleChild: AcpSessionRuntime.AcpOwnedPosixProcess = { - ...identity(200, 100, 200, 200, "stale-child"), - parentExecutable: `/proc/100/exe`, - parentStartTime: "100", - }; - const liveChild = identity(200, 100, 200, 200, "live-child"); - const root = identity(100, process.pid, 100, 100); - const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = { - ...root, - parentExecutable: `/proc/${process.pid}/exe`, - parentStartTime: "server", - }; - const ledger = new Map([ - [`100:${root.startTime}`, ownedRoot], - [`200:${staleChild.startTime}`, staleChild], - ]); - - AcpSessionRuntime.capturePosixOwnershipLedger({ - controller: makeController({ processes: [server(), root, liveChild] }).controller, - ledger, - rootPid: 100, - table: [root, liveChild], - }); - - expect([...ledger.values()].some((entry) => entry.startTime === "stale-child")).toBe(false); - const admitted = [...ledger.values()].find((entry) => entry.pid === 200); - expect(admitted?.startTime).toBe("live-child"); - }); - it.live("signals children forked under a still-live parent during the same pass", () => Effect.gen(function* () { let forked = false; diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index 8bb03d3469cb..b36c4afe9159 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -4,7 +4,6 @@ import { ANTIGRAVITY_DEFAULT_MODEL, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ChatAttachment, - type RuntimeMode, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -14,7 +13,6 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { - antigravityPermissionMode, applyAntigravityAcpModelSelection, buildAntigravityPrompt, } from "./AntigravityAcpSupport.ts"; @@ -60,22 +58,6 @@ describe("applyAntigravityAcpModelSelection", () => { }), ); - it.effect("reapplies an explicit selection even when setup reports the same model", () => - Effect.gen(function* () { - const { runtime, selections } = makeModelRuntime([ - { ...modelConfig, currentValue: "gemini-saved" }, - ]); - const model = yield* applyAntigravityAcpModelSelection({ - runtime, - model: "gemini-saved", - mapError: (cause) => cause, - }); - - expect(model).toBe("gemini-saved"); - expect(selections).toEqual(["gemini-saved"]); - }), - ); - it.effect.each([undefined, null, ANTIGRAVITY_DEFAULT_MODEL])( "uses the native default for %s without sending an internal model ID", (requestedModel) => @@ -154,34 +136,6 @@ describe("applyAntigravityAcpModelSelection", () => { expect(selections).toEqual(["gemini-saved"]); }), ); - - it.effect("reports a native model-selection failure through the adapter error mapper", () => - Effect.gen(function* () { - const nativeError = EffectAcpErrors.AcpRequestError.invalidParams("Model access changed."); - const { runtime } = makeModelRuntime([modelConfig], nativeError); - const error = yield* applyAntigravityAcpModelSelection({ - runtime, - model: "gemini-saved", - mapError: (cause) => ({ operation: "select-model", cause }), - }).pipe(Effect.flip); - - expect(error).toEqual({ operation: "select-model", cause: nativeError }); - }), - ); -}); - -describe("antigravityPermissionMode", () => { - it.each([ - { runtimeMode: "approval-required", nativeMode: "default" }, - { runtimeMode: "auto", nativeMode: "default" }, - { runtimeMode: "auto-accept-edits", nativeMode: "auto_edit" }, - { runtimeMode: "full-access", nativeMode: "yolo" }, - ] satisfies ReadonlyArray<{ runtimeMode: RuntimeMode; nativeMode: string }>)( - "maps $runtimeMode to $nativeMode", - ({ runtimeMode, nativeMode }) => { - expect(antigravityPermissionMode(runtimeMode)).toBe(nativeMode); - }, - ); }); const imageAttachment = { @@ -298,30 +252,6 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); - it.effect("rejects missing folded clipboard text", () => - Effect.gen(function* () { - const fixture = yield* makeAttachmentFixture(); - const pastedText = { - ...textAttachment, - name: "pasted-text.txt", - mimeType: "text/plain", - source: { _tag: "pasted-text" as const }, - } satisfies ChatAttachment; - - const error = yield* buildAntigravityPrompt({ - input: "Inspect the pasted text only as needed.", - attachments: [pastedText], - attachmentsDir: fixture.attachmentsDir, - }).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "AcpRequestError", - code: -32602, - errorMessage: "Could not read attachment 'pasted-text.txt'.", - }); - }), - ); - it.effect.each([ { name: "archive.zip", mimeType: "application/zip" }, { name: "clip.mp4", mimeType: "video/mp4" }, @@ -381,34 +311,6 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); - it.effect("uses a PDF file resource link without copying or reading its bytes", () => - Effect.gen(function* () { - const fixture = yield* makeAttachmentFixture(); - const upload = yield* fixture.write(pdfAttachment, "%PDF-1.7\n"); - const forbidFileAccess = () => - Effect.die("PDF prompt construction must only inspect the file."); - const prompt = yield* buildAntigravityPrompt({ - input: undefined, - attachments: [pdfAttachment], - attachmentsDir: fixture.attachmentsDir, - }).pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fixture.fs, - readFile: forbidFileAccess, - writeFile: forbidFileAccess, - writeFileString: forbidFileAccess, - copyFile: forbidFileAccess, - copy: forbidFileAccess, - rename: forbidFileAccess, - }), - ); - - expect(prompt).toEqual([ - { type: "resource_link", uri: upload.uri, name: "report.pdf", mimeType: "application/pdf" }, - ]); - }), - ); - it.effect.each([ { ...imageAttachment, name: "animation.gif", mimeType: "image/gif" }, ] satisfies ReadonlyArray)( @@ -583,21 +485,4 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }); }), ); - - it.effect("rejects an empty turn", () => - Effect.gen(function* () { - const fixture = yield* makeAttachmentFixture(); - const error = yield* buildAntigravityPrompt({ - input: " ", - attachments: [], - attachmentsDir: fixture.attachmentsDir, - }).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "AcpRequestError", - code: -32602, - errorMessage: "A turn requires text or supported attachments.", - }); - }), - ); }); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts index cfc7a6adfbe1..20d084c4bf81 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.test.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -454,28 +454,6 @@ describe("Antigravity tool results", () => { expect(serialized).not.toContain("inline-data-url"); }); - it("keeps large assistant replies and startup command metadata unchanged", () => { - const notifications = [ - { - sessionId: "session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "a".repeat(20_000) }, - }, - }, - { - sessionId: "session-1", - update: { - sessionUpdate: "available_commands_update", - availableCommands: [{ name: "plan", description: "Make a plan" }], - }, - }, - ] satisfies ReadonlyArray; - for (const notification of notifications) { - expect(normalizeAntigravitySessionUpdate(notification)).toBe(notification); - } - }); - it("tracks only commands that have passed approval and are still running", () => { const running = normalizeAntigravityToolCall(parseToolUpdate(commandStarted).toolCall); expect(isAntigravityOpenCommand(running)).toBe(true); diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index dad00aa76034..9138a28dab5b 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -1,6 +1,3 @@ -// @effect-diagnostics-next-line nodeBuiltinImport:off -import * as NodeChildProcess from "node:child_process"; - import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -15,20 +12,15 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Ndjson from "effect/encoding/Ndjson"; -import * as ChildProcess from "effect/process/ChildProcess"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as AcpErrors from "effect-acp/errors"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { ANTIGRAVITY_AUTH_BROWSER_MARKER, ANTIGRAVITY_AUTH_STDOUT_PREFIX, - ANTIGRAVITY_PERSONAL_AUTH, ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, type AntigravityAuthConfig, - antigravityAuthConfigIssue, type AntigravityProfile, - antigravityProfileSettings, buildAntigravityAcpSpawnInput, isAntigravitySignInRequiredError, makeAntigravityStderrHandler, @@ -150,47 +142,6 @@ describe("Antigravity process environment", () => { expect(business.GOOGLE_API_KEY).toBeUndefined(); }); - it("writes the auth method and GCP block into the agent's settings.json", () => { - expect( - decodeJson( - antigravityProfileSettings({ - authMethod: "oauth-business", - apiKey: "never-written", - gcpProject: "proj", - gcpLocation: "us-central1", - }), - ), - ).toEqual({ - auth: { type: "oauth-business" }, - gcp: { project: "proj", location: "us-central1" }, - }); - expect(decodeJson(antigravityProfileSettings(ANTIGRAVITY_PERSONAL_AUTH))).toEqual({ - auth: { type: "oauth-personal" }, - }); - }); - - it("names the missing credential for each method", () => { - expect(antigravityAuthConfigIssue(ANTIGRAVITY_PERSONAL_AUTH)).toBeNull(); - expect( - antigravityAuthConfigIssue({ ...ANTIGRAVITY_PERSONAL_AUTH, authMethod: "gemini-api-key" }), - ).toContain("API key"); - expect( - antigravityAuthConfigIssue({ - ...ANTIGRAVITY_PERSONAL_AUTH, - authMethod: "oauth-business", - gcpProject: "proj", - }), - ).toContain("location"); - expect( - antigravityAuthConfigIssue({ - ...ANTIGRAVITY_PERSONAL_AUTH, - authMethod: "agent-platform", - gcpProject: "proj", - gcpLocation: "us-central1", - }), - ).toBeNull(); - }); - it("isolates TEMP and TMP to the profile directory on Windows", () => { const windowsProfile: AntigravityProfile = { platform: "win32", @@ -220,30 +171,6 @@ describe("Antigravity process environment", () => { expect(perRun.env?.TMP).toBe(`${windowsProfile.tempDirectory}\\run-1`); }); - it("isolates TMPDIR to the profile directory on POSIX hosts", () => { - const spawn = buildAntigravityAcpSpawnInput({ - installation: { executablePath: "/release/acp", harnessPath: "/release/harness" }, - profile, - cwd: "/project", - baseEnv: { TMPDIR: "/tmp" }, - runtimeTempDirectory: `${profile.tempDirectory}/run-1`, - }); - expect(spawn.env?.TMPDIR).toBe(`${profile.tempDirectory}/run-1`); - expect(spawn.env?.TEMP).toBeUndefined(); - }); - - it("uses the registry launch arguments for each supported host platform", () => { - for (const platform of ["linux", "darwin", "win32"] as const) { - const spawn = buildAntigravityAcpSpawnInput({ - installation: { executablePath: "/release/acp", harnessPath: "/release/harness" }, - profile: { ...profile, platform }, - cwd: "/project", - baseEnv: {}, - }); - expect(spawn.args).toEqual(platform === "linux" ? ["--uid="] : []); - } - }); - it.effect("keeps accounts separate even when instance IDs differ only by case", () => Effect.gen(function* () { const first = yield* resolveAntigravityInstanceDirectories( @@ -284,16 +211,6 @@ describe("Antigravity process environment", () => { }); describe("Antigravity authorization URL", () => { - it.effect("returns the official Google URL and its owned loopback target", () => - Effect.gen(function* () { - expect(yield* parseAntigravityAuthorizationUrl(authorizationUrl)).toEqual({ - authorizationUrl, - redirectUri: "http://127.0.0.1:46353/", - state: "test-opaque-state", - }); - }), - ); - it.effect( "rejects other origins, ambiguous state, and non-loopback redirects without retaining them", () => @@ -377,17 +294,6 @@ describe("Antigravity stdout compatibility", () => { }), ); - it.effect("handles an auth line without a final newline", () => - Effect.gen(function* () { - const urls: string[] = []; - const result = yield* makeAntigravityStdoutTransform({ - onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), - })(Stream.make(encode(authLine.slice(0, -1)))).pipe(Stream.runCollect); - expect(result).toEqual([]); - expect(urls).toEqual([authorizationUrl]); - }), - ); - it.effect("ends normal work with a safe sign-in error instead of waiting for OAuth", () => Effect.gen(function* () { const result = yield* makeAntigravityStdoutTransform()(Stream.make(encode(authLine))).pipe( @@ -484,22 +390,6 @@ describe("Antigravity stderr compatibility", () => { }), ); - it.effect("preserves failures from the sign-in flow owner", () => - Effect.gen(function* () { - const failure = new AcpErrors.AcpTransportError({ - detail: "The sign-in flow stopped.", - cause: undefined, - }); - const handleStderr = makeAntigravityStderrHandler({ - onAuthorizationUrl: () => Effect.fail(failure), - }); - const error = yield* handleStderr( - `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`, - ).pipe(Effect.flip); - expect(error).toBe(failure); - }), - ); - it.effect("forwards an accepted browser-helper URL larger than 8 KiB", () => Effect.gen(function* () { const urls: string[] = []; @@ -556,6 +446,72 @@ describe("Antigravity stderr compatibility", () => { }); it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { + const prepareSpawn = Effect.fn(function* ( + bundles: ReadonlyArray, + baseEnv: NodeJS.ProcessEnv = {}, + platform: NodeJS.Platform = "linux", + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const profile = yield* prepareAntigravityProfile({ + profileDirectory: path.join(directory, "profile"), + userHome: path.join(directory, "home"), + runtimeExecutablePath: process.execPath, + platform, + baseEnv, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + exists: (candidate) => Effect.succeed(bundles.includes(candidate)), + }), + ); + return buildAntigravityAcpSpawnInput({ + installation: { executablePath: "/release/acp", harnessPath: "/release/harness" }, + profile, + cwd: directory, + baseEnv, + }).env; + }); + + // A Windows Node path cannot preflight the browser helper as a POSIX host. + const caTest = it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32"); + + caTest( + "launches Linux agents with the first available CA bundle, preferring absolute Nix paths", + () => + Effect.gen(function* () { + const systemBundles = ["/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/cert.pem"]; + const nixBundle = "/nix/store/nss-cacert/etc/ssl/certs/ca-bundle.crt"; + expect((yield* prepareSpawn(systemBundles))?.SSL_CERT_FILE).toBe(systemBundles[0]); + expect( + (yield* prepareSpawn([nixBundle, ...systemBundles], { NIX_SSL_CERT_FILE: nixBundle })) + ?.SSL_CERT_FILE, + ).toBe(nixBundle); + expect( + (yield* prepareSpawn(systemBundles, { NIX_SSL_CERT_FILE: nixBundle }))?.SSL_CERT_FILE, + ).toBe(systemBundles[0]); + expect( + (yield* prepareSpawn(["certs/local.pem", ...systemBundles], { + NIX_SSL_CERT_FILE: "certs/local.pem", + }))?.SSL_CERT_FILE, + ).toBe(systemBundles[0]); + expect((yield* prepareSpawn([]))?.SSL_CERT_FILE).toBeUndefined(); + }), + ); + + caTest("preserves explicit certificate files and directories in Linux launches", () => + Effect.gen(function* () { + const bundles = ["/etc/ssl/certs/ca-certificates.crt"]; + expect( + (yield* prepareSpawn(bundles, { SSL_CERT_FILE: "/custom/ca.pem" }))?.SSL_CERT_FILE, + ).toBe("/custom/ca.pem"); + const directoryEnv = yield* prepareSpawn(bundles, { SSL_CERT_DIR: "/custom/certs" }); + expect(directoryEnv?.SSL_CERT_DIR).toBe("/custom/certs"); + expect(directoryEnv?.SSL_CERT_FILE).toBeUndefined(); + }), + ); + it.effect("runs the browser helper with installed Node in standalone builds", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -683,61 +639,6 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { }), ); - it.effect("keeps the browser helper successful when cancellation closes stderr", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); - let helperCommand: ChildProcess.StandardCommand | undefined; - yield* prepareAntigravityProfile({ profileDirectory: temporaryDirectory }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - if (ChildProcess.isStandardCommand(command)) helperCommand = command; - return spawner.spawn(command); - }), - ), - ); - expect(helperCommand).toBeDefined(); - if (!helperCommand) return; - const command = helperCommand; - const child = yield* Effect.acquireRelease( - Effect.sync(() => - NodeChildProcess.spawn(command.command, command.args, { - env: { ...command.options.env }, - stdio: ["ignore", "ignore", "pipe"], - }), - ), - (process) => Effect.sync(() => void process.kill()), - ); - child.stderr?.destroy(); - const exitCode = yield* Effect.promise( - () => - new Promise((resolve, reject) => { - child.once("error", reject); - child.once("exit", resolve); - }), - ); - expect(exitCode).toBe(0); - }), - ); - - it.effect("fails before creating a profile when the helper cannot start", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); - const profileDirectory = path.join(temporaryDirectory, "unused-profile"); - const result = yield* prepareAntigravityProfile({ - profileDirectory, - runtimeExecutablePath: path.join(temporaryDirectory, "missing-runtime"), - }).pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - expect(yield* fs.exists(profileDirectory)).toBe(false); - }), - ); - it.effect("rejects Python BROWSER delimiter collisions before starting a helper", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 216dbc7aa858..4e4d32ffc107 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -87,6 +87,7 @@ export interface AntigravityProfile { readonly tokenPath: string; readonly tempDirectory: string; readonly browserCommand: string; + readonly caBundlePath?: string; } export interface AntigravityAuthConfig { @@ -96,7 +97,7 @@ export interface AntigravityAuthConfig { readonly gcpLocation: string; } -export const ANTIGRAVITY_PERSONAL_AUTH: AntigravityAuthConfig = { +const ANTIGRAVITY_PERSONAL_AUTH: AntigravityAuthConfig = { authMethod: "oauth-personal", apiKey: "", gcpProject: "", @@ -141,7 +142,7 @@ export function antigravityAuthConfigIssue(auth: AntigravityAuthConfig): string * `auth.type` names the selected method so a native logout clears only that method's credentials * instead of every stored token. Never holds a credential. */ -export function antigravityProfileSettings(auth: AntigravityAuthConfig): string { +function antigravityProfileSettings(auth: AntigravityAuthConfig): string { const gcp = { ...(auth.gcpProject ? { project: auth.gcpProject } : {}), ...(auth.gcpLocation ? { location: auth.gcpLocation } : {}), @@ -221,6 +222,11 @@ function antigravityEnvironment( return { ...environment, ...credential, + ...(profile.caBundlePath !== undefined && + baseEnv.SSL_CERT_FILE === undefined && + baseEnv.SSL_CERT_DIR === undefined + ? { SSL_CERT_FILE: profile.caBundlePath } + : {}), GEMINI_HOME: profile.geminiHome, AGY_ACP_FORCE_FILE_STORAGE: "1", BROWSER: profile.browserCommand, @@ -327,6 +333,27 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( const geminiHome = path.resolve(input.profileDirectory); const acpDirectory = path.join(geminiHome, "antigravity-acp"); const tempDirectory = input.tempDirectory ?? path.join(acpDirectory, "tmp"); + let caBundlePath: string | undefined; + if (platform === "linux") { + // Bundled Python may look for certificates at paths absent on this distribution. + // Relative paths would resolve differently when the agent runs in the project directory. + for (const candidate of [ + (input.baseEnv ?? process.env).NIX_SSL_CERT_FILE, + "/etc/ssl/certs/ca-bundle.crt", + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/cert.pem", + ]) { + if ( + candidate && + path.isAbsolute(candidate) && + (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) + ) { + caBundlePath = candidate; + break; + } + } + } const profile: AntigravityProfile = { platform, geminiHome, @@ -334,6 +361,7 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( tokenPath: path.join(acpDirectory, "acp_token.json"), tempDirectory, browserCommand, + ...(caBundlePath === undefined ? {} : { caBundlePath }), }; const environment = antigravityEnvironment(profile, input.baseEnv ?? process.env, auth); yield* Effect.gen(function* () { diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index 6fa7f65f8431..d494c4924d0e 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -284,21 +284,6 @@ describe("Antigravity subscription limits", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("preserves cached access token when quota endpoints return no usable windows", () => - Effect.gen(function* () { - const { probe, requests } = yield* fixture({ - summary: { groups: [] }, - models: { models: {} }, - }); - const first = yield* probe; - expect(first.unavailable?.reason).toBe("probeFailed"); - expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(1); - const second = yield* probe; - expect(second.unavailable?.reason).toBe("probeFailed"); - expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(1); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("returns a safe failure when OAuth refresh fails", () => Effect.gen(function* () { const { probe } = yield* fixture({ tokenStatus: 401 }); diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index c946afb66832..8db751657454 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -1,13 +1,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse, UrlParams } from "effect/http"; import { describe, expect, it } from "vite-plus/test"; @@ -209,41 +206,6 @@ effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { }), ); - it.effect("times out a stalled claim body", () => - Effect.gen(function* () { - const login = yield* writeLogin; - const readingBody = yield* Deferred.make(); - const client = HttpClient.make((request) => { - const response = HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); - Object.defineProperty(response, "json", { - value: Deferred.succeed(readingBody, undefined).pipe(Effect.andThen(Effect.never)), - }); - return Effect.succeed(response); - }); - const claim = yield* ClaudeResetCredits.consumeClaudeResetCredit({ - ...login, - version: "2.1.0", - grantId: "grant_a", - requestId: "r-1", - }).pipe( - Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService(HttpClient.HttpClient, client), - Effect.result, - Effect.forkChild, - ); - yield* Deferred.await(readingBody); - yield* TestClock.adjust("26 seconds"); - expect(yield* Fiber.join(claim)).toMatchObject({ - _tag: "Failure", - failure: { - _tag: "ClaudeResetCreditError", - reason: "requestFailed", - cause: { _tag: "TimeoutError" }, - }, - }); - }).pipe(Effect.provide(TestClock.layer())), - ); - it.effect("refuses malformed ids without sending anything", () => Effect.gen(function* () { for (const ids of [ diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 4736fbe49708..fbc3efa2e5d4 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -122,25 +122,28 @@ export function claudeResetCreditsToContract( }; } -const readJson = (schema: S, file: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.readFileString(file).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), - }), - Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(schema))), - ); - }); +const readJson = Effect.fn("claudeResetCredits.readJson")(function* ( + schema: S, + file: string, +) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(file).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(schema))), + ); +}); -const readAccessToken = (configDir: string) => - Effect.gen(function* () { - if ((yield* HostProcess.Platform) === "darwin") return undefined; - const path = yield* Path.Path; - const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json")); - return credentials.claudeAiOauth?.accessToken?.trim() || undefined; - }); +const readAccessToken = Effect.fn("claudeResetCredits.readAccessToken")(function* ( + configDir: string, +) { + if ((yield* HostProcess.Platform) === "darwin") return undefined; + const path = yield* Path.Path; + const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json")); + return credentials.claudeAiOauth?.accessToken?.trim() || undefined; +}); const withClaudeHeaders = (token: string, version: string) => HttpClientRequest.setHeaders({ diff --git a/apps/server/src/provider/claudeUsageLimits.test.ts b/apps/server/src/provider/claudeUsageLimits.test.ts index a2da39b00d72..7fc0a63a605d 100644 --- a/apps/server/src/provider/claudeUsageLimits.test.ts +++ b/apps/server/src/provider/claudeUsageLimits.test.ts @@ -86,15 +86,6 @@ describe("claudeUsageResponseToLimits", () => { ).toEqual({ overageIncluded: "Fable" }); }); - it("reports API key and Bedrock accounts as unsupported", () => { - expect( - claudeUsageResponseToLimits({ - checkedAt, - response: { rate_limits_available: false, rate_limits: null }, - }).limits, - ).toEqual({ checkedAt, windows: [], unavailable: { reason: "unsupported" } }); - }); - it("skips a window the endpoint reports without a utilization", () => { expect( claudeUsageResponseToLimits({ @@ -117,6 +108,121 @@ describe("claudeUsageResponseToLimits", () => { }, ]); }); + + const enterpriseResponse = { + rate_limits_available: true, + rate_limits: { + five_hour: null, + seven_day: null, + spend: { + enabled: true, + used: { amount_minor: 4_631, currency: "USD", exponent: 2 }, + limit: { amount_minor: 50_000, currency: "USD", exponent: 2 }, + percent: 9, + }, + extra_usage: { + is_enabled: true, + used_credits: 4_631, + monthly_limit: 50_000, + utilization: 9.261999999999999, + currency: "USD", + decimal_places: 2, + }, + }, + }; + const monthlyWindow = { + id: "monthly_spend", + kind: "monthly", + label: "Monthly spend", + usedPercent: expect.closeTo(9.262, 10), + }; + + it("maps the captured Enterprise budget precisely, preferring spend beside rolling windows", () => { + expect(claudeUsageResponseToLimits({ checkedAt, response: enterpriseResponse }).limits).toEqual( + { + checkedAt, + windows: [monthlyWindow], + }, + ); + const response = { + ...enterpriseResponse, + rate_limits: { + ...enterpriseResponse.rate_limits, + five_hour: { utilization: 12, resets_at: null }, + extra_usage: { + ...enterpriseResponse.rate_limits.extra_usage, + used_credits: 30_000, + utilization: 60, + }, + }, + }; + expect(claudeUsageResponseToLimits({ checkedAt, response }).limits.windows).toEqual([ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 12, + windowDurationMins: 300, + }, + monthlyWindow, + ]); + }); + + it("falls back to enabled extra usage amounts or utilization when spend is unusable", () => { + const response = { + ...enterpriseResponse, + rate_limits: { + ...enterpriseResponse.rate_limits, + spend: { + ...enterpriseResponse.rate_limits.spend, + enabled: false, + used: { amount_minor: 30_000, currency: "USD", exponent: 2 }, + }, + extra_usage: { ...enterpriseResponse.rate_limits.extra_usage, utilization: 9 }, + }, + }; + expect(claudeUsageResponseToLimits({ checkedAt, response }).limits.windows).toEqual([ + monthlyWindow, + ]); + const utilizationOnly = { + ...response, + rate_limits: { + ...response.rate_limits, + spend: { enabled: true, used: null, limit: null, percent: 9 }, + extra_usage: { + ...enterpriseResponse.rate_limits.extra_usage, + used_credits: null, + monthly_limit: null, + }, + }, + }; + expect( + claudeUsageResponseToLimits({ checkedAt, response: utilizationOnly }).limits.windows, + ).toEqual([monthlyWindow]); + }); + + it("omits disabled budgets and refuses invalid money without an enabled fallback", () => { + const rateLimits = enterpriseResponse.rate_limits; + const response = { + ...enterpriseResponse, + rate_limits: { + ...rateLimits, + spend: { ...rateLimits.spend, enabled: false }, + extra_usage: { ...rateLimits.extra_usage, is_enabled: false }, + }, + }; + expect(claudeUsageResponseToLimits({ checkedAt, response }).limits.windows).toEqual([]); + response.rate_limits.spend = { + ...rateLimits.spend, + limit: { ...rateLimits.spend.limit, currency: "EUR" }, + }; + expect(claudeUsageResponseToLimits({ checkedAt, response }).limits.windows).toEqual([]); + response.rate_limits.spend = { + ...rateLimits.spend, + limit: { ...rateLimits.spend.limit, amount_minor: 0 }, + }; + expect(claudeUsageResponseToLimits({ checkedAt, response }).limits.windows).toEqual([]); + }); }); describe("claudeRateLimitEventToUpdate", () => { @@ -164,16 +270,4 @@ describe("claudeRateLimitEventToUpdate", () => { ], }); }); - - it("ignores windows the page does not render and events without a utilization", () => { - expect( - claudeRateLimitEventToUpdate( - { status: "allowed", rateLimitType: "seven_day_opus", utilization: 0.1 }, - noNames, - ), - ).toBeUndefined(); - expect( - claudeRateLimitEventToUpdate({ status: "rejected", rateLimitType: "five_hour" }, noNames), - ).toBeUndefined(); - }); }); diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index a4dd6d0152be..e41dac250ace 100644 --- a/apps/server/src/provider/claudeUsageLimits.ts +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -8,6 +8,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import { clampPercent, @@ -34,6 +35,16 @@ const WINDOWS: Readonly< */ const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included"; +// The SDK does not yet type the spend budget returned by `get_usage`. +const SpendMoney = Schema.Struct({ + amount_minor: Schema.Natural, + currency: Schema.NonEmptyString, + exponent: Schema.Natural, +}); +const decodeSpendBudget = Schema.decodeUnknownOption( + Schema.Struct({ enabled: Schema.Boolean, used: SpendMoney, limit: SpendMoney }), +); + export interface ClaudeScopedLimitNames { readonly overageIncluded: string | undefined; } @@ -61,6 +72,44 @@ function scopedWindow( }; } +function percentFromAmounts(used: number | null | undefined, limit: number | null | undefined) { + if ( + typeof used !== "number" || + typeof limit !== "number" || + !Number.isFinite(used) || + !Number.isFinite(limit) || + used < 0 || + limit <= 0 + ) { + return undefined; + } + const percent = (used / limit) * 100; + return Number.isFinite(percent) ? clampPercent(percent) : undefined; +} + +function readSpendPercent(rateLimits: NonNullable) { + const spend = Option.getOrUndefined( + decodeSpendBudget((rateLimits as { readonly spend?: unknown }).spend), + ); + // Minor-unit ratios require matching currency and scale; spend.percent is rounded. + if ( + spend?.enabled && + spend.used.currency.trim().length > 0 && + spend.used.currency.trim() === spend.limit.currency.trim() && + spend.used.exponent === spend.limit.exponent + ) { + const percent = percentFromAmounts(spend.used.amount_minor, spend.limit.amount_minor); + if (percent !== undefined) return percent; + } + + const extra = rateLimits.extra_usage; + if (extra?.is_enabled !== true) return undefined; + const percent = percentFromAmounts(extra.used_credits, extra.monthly_limit) ?? extra.utilization; + return typeof percent === "number" && Number.isFinite(percent) && percent >= 0 + ? clampPercent(percent) + : undefined; +} + /** * `model_scoped` shipped in the CLI after the SDK typings we pin, so it is * read structurally until the `.d.ts` catches up. @@ -142,7 +191,10 @@ export function claudeUsageResponseToLimits(input: { const { response, checkedAt } = input; if (!response.rate_limits_available || !response.rate_limits) { return { - limits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }), + limits: makeUnavailableUsageLimits({ + checkedAt, + reason: response.rate_limits_available ? "probeFailed" : "unsupported", + }), names: { overageIncluded: undefined }, }; } @@ -162,6 +214,15 @@ export function claudeUsageResponseToLimits(input: { ); overageIncluded ??= entry.display_name; } + const spendPercent = readSpendPercent(response.rate_limits); + if (spendPercent !== undefined) { + windows.push({ + id: "monthly_spend", + kind: "monthly", + label: "Monthly spend", + usedPercent: spendPercent, + }); + } return { limits: makeUsageLimits({ checkedAt, windows }), names: { overageIncluded }, diff --git a/apps/server/src/provider/codexLaunchArgs.test.ts b/apps/server/src/provider/codexLaunchArgs.test.ts index a7dfc81fae86..ce18c8f3f1bd 100644 --- a/apps/server/src/provider/codexLaunchArgs.test.ts +++ b/apps/server/src/provider/codexLaunchArgs.test.ts @@ -2,31 +2,7 @@ import * as NodeAssert from "node:assert/strict"; import { describe, it } from "vite-plus/test"; -import { - codexAppServerArgs, - codexExecLaunchArgs, - resolveCodexLaunchArgs, -} from "./codexLaunchArgs.ts"; - -describe("resolveCodexLaunchArgs", () => { - it("uses T3CODE_CODEX_LAUNCH_ARGS before configured settings", () => { - NodeAssert.equal( - resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: "--enable foo" }), - "--enable foo", - ); - }); - - it("uses configured settings when T3CODE_CODEX_LAUNCH_ARGS is empty", () => { - NodeAssert.equal( - resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: " " }), - "--strict-config", - ); - }); - - it("ignores whitespace-only environment values", () => { - NodeAssert.equal(resolveCodexLaunchArgs("", { T3CODE_CODEX_LAUNCH_ARGS: " " }), ""); - }); -}); +import { codexAppServerArgs, codexExecLaunchArgs } from "./codexLaunchArgs.ts"; describe("codexAppServerArgs", () => { it("preserves escaped TOML quotes in config values", () => { @@ -35,29 +11,9 @@ describe("codexAppServerArgs", () => { ["app-server", "--config", 'model="gpt-6.1-sol"', "--strict-config"], ); }); - - it("returns the app-server command for empty launch args", () => { - NodeAssert.deepStrictEqual(codexAppServerArgs(""), ["app-server"]); - }); - - it("appends parsed launch args after app-server", () => { - NodeAssert.deepStrictEqual(codexAppServerArgs("--strict-config --enable foo"), [ - "app-server", - "--strict-config", - "--enable", - "foo", - ]); - }); }); describe("codexExecLaunchArgs", () => { - it("preserves escaped TOML quotes in config values", () => { - NodeAssert.deepStrictEqual( - codexExecLaunchArgs(String.raw`--config model=\"gpt-6.1-sol\" --strict-config`), - ["--config", 'model="gpt-6.1-sol"', "--strict-config"], - ); - }); - it("keeps shared codex flags and omits app-server-only flags", () => { NodeAssert.deepStrictEqual( codexExecLaunchArgs('--strict-config --enable foo --listen off --config model="gpt 5"'), diff --git a/apps/server/src/provider/codexUsageLimits.test.ts b/apps/server/src/provider/codexUsageLimits.test.ts index 8d310a9fff73..51970a3a5472 100644 --- a/apps/server/src/provider/codexUsageLimits.test.ts +++ b/apps/server/src/provider/codexUsageLimits.test.ts @@ -5,7 +5,6 @@ import { codexRateLimitsFailureMessage, codexRateLimitsToLimits, codexRateLimitsToUpdate, - codexResetCreditsToContract, codexUsageLimitMessage, mergeCodexRateLimits, } from "./codexUsageLimits.ts"; @@ -13,6 +12,34 @@ import { const checkedAt = "2026-07-18T10:00:00.000Z"; describe("codexRateLimitsToLimits", () => { + it("shows an Enterprise monthly credit allowance in reads and notifications without a duration", () => { + const snapshot = { + limitId: "codex", + planType: "enterprise_cbp_usage_based", + primary: null, + secondary: null, + individualLimit: { remainingPercent: 99, resetsAt: 1_784_500_000 }, + }; + const windows = [ + { + id: "individual", + kind: "monthly", + label: "Monthly credit limit", + usedPercent: 1, + resetsAt: "2026-07-19T22:26:40.000Z", + }, + ]; + + expect( + codexRateLimitsToLimits({ + checkedAt, + snapshot: { limitId: "spark" }, + rateLimitsByLimitId: { codex: snapshot }, + }), + ).toEqual({ checkedAt, windows }); + expect(codexRateLimitsToUpdate(snapshot)).toEqual({ windows }); + }); + it("maps primary and secondary onto the session and weekly windows", () => { expect( codexRateLimitsToLimits({ @@ -66,6 +93,7 @@ describe("codexRateLimitsToLimits", () => { it("selects the main Codex allowance and leaves Spark out", () => { const spark = { limitId: "codex_bengalfox", + individualLimit: { remainingPercent: 99, resetsAt: 1_784_500_000 }, primary: { usedPercent: 0, windowDurationMins: 300 }, secondary: { usedPercent: 90, windowDurationMins: 10080 }, }; @@ -98,18 +126,6 @@ describe("codexRateLimitsToLimits", () => { ); }, ); - - it("does not show a model-specific legacy snapshot as the main allowance", () => { - expect( - codexRateLimitsToLimits({ - checkedAt, - snapshot: { - limitId: "codex_bengalfox", - secondary: { usedPercent: 90 }, - }, - }).windows, - ).toEqual([]); - }); }); describe("codexRateLimitsToUpdate", () => { @@ -136,6 +152,7 @@ describe("codexRateLimitsToUpdate", () => { expect( codexRateLimitsToUpdate({ limitId: "codex_bengalfox", + individualLimit: { remainingPercent: 99, resetsAt: 1_784_500_000 }, primary: { usedPercent: 0, windowDurationMins: 300 }, secondary: { usedPercent: 90, windowDurationMins: 10080 }, }), @@ -169,39 +186,6 @@ describe("codexRateLimitsFailureMessage", () => { ), ).toBe("Codex could not read usage (JSON-RPC -32603)."); }); - - it("phrases a dead process differently from a bad answer", () => { - expect( - codexRateLimitsFailureMessage(new CodexErrors.CodexAppServerProcessExitedError({ code: 1 })), - ).toBe("Codex exited before it could report usage."); - }); -}); - -describe("codexResetCreditsToContract", () => { - it("counts available credits and reports the soonest expiry", () => { - expect( - codexResetCreditsToContract({ - availableCount: 2, - credits: [ - { status: "available", expiresAt: 1_784_500_000 }, - { status: "redeemed", expiresAt: 1_700_000_000 }, - { status: "available", expiresAt: 1_784_000_000 }, - ], - }), - ).toEqual({ availableCount: 2, nextExpiresAt: "2026-07-14T03:33:20.000Z" }); - expect(codexResetCreditsToContract({ availableCount: 0 })).toEqual({ availableCount: 0 }); - expect(codexResetCreditsToContract(null)).toBeUndefined(); - }); - - it("rides along on the probe's limits", () => { - expect( - codexRateLimitsToLimits({ - checkedAt, - snapshot: { primary: { usedPercent: 5, windowDurationMins: 300 } }, - resetCredits: { availableCount: 1 }, - }).resetCredits, - ).toEqual({ availableCount: 1 }); - }); }); describe("codexUsageLimitMessage", () => { @@ -246,42 +230,42 @@ describe("codexUsageLimitMessage", () => { "Codex usage limit reached. The session limit resets in 3h 20m. The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets.", ); }); - - it("names no window when credits run out without one", () => { - expect( - codexUsageLimitMessage( - { limitId: "codex", rateLimitReachedType: "workspace_member_credits_depleted" }, - at, - ), - ).toBe( - "Codex usage limit reached. The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.", - ); - }); - - it("says only what it knows without a snapshot", () => { - expect(codexUsageLimitMessage(undefined, at)).toBe( - "Codex usage limit reached. Send the message again once the limit resets.", - ); - }); }); describe("mergeCodexRateLimits", () => { - it("keeps windows an update does not carry", () => { - const merged = mergeCodexRateLimits( - { - limitId: "codex", - planType: "business", - primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, - }, - { rateLimitReachedType: "rate_limit_reached" }, - ); - - expect(merged).toEqual({ + it("updates monthly credit usage, retains it when omitted, and clears an explicit null", () => { + const snapshot = { limitId: "codex", - planType: "business", - rateLimitReachedType: "rate_limit_reached", - primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, + primary: { usedPercent: 12 }, + individualLimit: { remainingPercent: 99, resetsAt: 1_784_500_000 }, + }; + const individualLimit = { remainingPercent: 75, resetsAt: 1_787_000_000 }; + const changed = mergeCodexRateLimits(snapshot, { individualLimit }); + expect(changed).toEqual({ ...snapshot, individualLimit }); + expect(codexRateLimitsToUpdate(changed!)).toEqual({ + windows: [ + { + id: "primary", + kind: "session", + label: "Session", + usedPercent: 12, + windowDurationMins: 300, + }, + { + id: "individual", + kind: "monthly", + label: "Monthly credit limit", + usedPercent: 25, + resetsAt: "2026-08-17T20:53:20.000Z", + }, + ], }); + + const retained = mergeCodexRateLimits(changed, { primary: null }); + expect(retained).toEqual({ ...snapshot, primary: null, individualLimit }); + const cleared = mergeCodexRateLimits(retained, { individualLimit: null }); + expect(cleared).toEqual({ ...snapshot, primary: null, individualLimit: null }); + expect(codexRateLimitsToLimits({ checkedAt, snapshot: cleared! }).windows).toEqual([]); }); it("ignores a model-specific snapshot so it cannot replace the main allowance", () => { @@ -292,6 +276,7 @@ describe("mergeCodexRateLimits", () => { expect( mergeCodexRateLimits(main, { limitId: "spark", + individualLimit: { remainingPercent: 99, resetsAt: 1_784_500_000 }, primary: { usedPercent: 3, resetsAt: 1_800_000_000, windowDurationMins: 300 }, }), ).toBe(main); diff --git a/apps/server/src/provider/codexUsageLimits.ts b/apps/server/src/provider/codexUsageLimits.ts index 31d48535cf94..0baae05dd816 100644 --- a/apps/server/src/provider/codexUsageLimits.ts +++ b/apps/server/src/provider/codexUsageLimits.ts @@ -22,6 +22,10 @@ export interface CodexRateLimitSnapshot { readonly rateLimitReachedType?: string | null; readonly primary?: CodexRateLimitWindow | null; readonly secondary?: CodexRateLimitWindow | null; + readonly individualLimit?: { + readonly remainingPercent: number; + readonly resetsAt: number; + } | null; } export interface CodexResetCreditsSummary { @@ -84,10 +88,21 @@ function codexRateLimitsToWindows( ...(resetsAt ? { resetsAt } : {}), }); } + const individualLimit = snapshot.individualLimit; + if (individualLimit && Number.isFinite(individualLimit.remainingPercent)) { + const resetsAt = isoFromEpochSeconds(individualLimit.resetsAt); + windows.push({ + id: "individual", + kind: "monthly", + label: "Monthly credit limit", + usedPercent: clampPercent(100 - individualLimit.remainingPercent), + ...(resetsAt ? { resetsAt } : {}), + }); + } return windows; } -export function codexResetCreditsToContract( +function codexResetCreditsToContract( summary: CodexResetCreditsSummary | null | undefined, ): ServerProviderResetCredits | undefined { if (!summary) return undefined; @@ -166,6 +181,7 @@ export function mergeCodexRateLimits( : {}), ...(update.primary !== undefined ? { primary: update.primary } : {}), ...(update.secondary !== undefined ? { secondary: update.secondary } : {}), + ...(update.individualLimit !== undefined ? { individualLimit: update.individualLimit } : {}), }; } diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 6b950ed2d060..e62f661fcfc2 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-09T23:31:10Z", + "updatedAt": "2026-10-21T06:20:00Z", "compatibility": [ { "driver": "codex", @@ -134,7 +134,9 @@ { "slug": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "status": "legacy" }, { "slug": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "status": "legacy" }, { "slug": "gpt-5.6-luna", "name": "GPT-5.6 Luna", "status": "legacy" }, - { "slug": "gpt-5.5", "name": "GPT-5.5", "status": "legacy" } + { "slug": "gpt-5.5", "name": "GPT-5.5", "status": "legacy" }, + { "slug": "gpt-5.4-mini", "name": "GPT-5.4 Mini", "status": "legacy" }, + { "slug": "gpt-5.3-codex-spark", "name": "GPT-5.3 Codex Spark", "status": "legacy" } ] }, "claudeAgent": { @@ -653,7 +655,8 @@ { "id": "thinking", "label": "Thinking", - "type": "boolean" + "type": "boolean", + "currentValue": true } ] }, diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index 0310c7998d90..dbb7a2bfc393 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -11,10 +11,10 @@ import * as Stream from "effect/Stream"; import * as ServerConfig from "../config.ts"; import * as ModelManifest from "./ModelManifest.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; -import { BUILT_IN_DRIVERS } from "./builtInDrivers.ts"; import * as Schema from "effect/Schema"; import { applyProviderCompatibility, @@ -50,24 +50,7 @@ const provider: ServerProvider = { slashCommands: [], }; -const V2_RELEASE = "0.0.46"; - describe("provider compatibility", () => { - it("bundles a compatibility policy for every built-in harness", () => { - for (const builtIn of BUILT_IN_DRIVERS) { - if (builtIn.driverKind === "acpRegistry") continue; - assert.isDefined( - resolveProviderCompatibility( - ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, - builtIn.driverKind, - null, - V2_RELEASE, - ), - `Missing bundled compatibility policy for ${builtIn.driverKind}`, - ); - } - }); - it("keeps releases before V2 on the OpenCode 1 policy they shipped with", () => { // Every installed build fetches main's manifest, and 0.0.45 and older run the // V1 orchestrator, which cannot drive OpenCode 2. @@ -96,44 +79,6 @@ describe("provider compatibility", () => { ); }); - it("supports OpenCode 2 and gives OpenCode 1.x limited support", () => { - const opencode = ProviderDriverKind.make("opencode"); - for (const [version, expected] of [ - ["2.0.18", "supported"], - ["2.1.0", "supported"], - ["2.0.17", "unsupported"], - ["2.0.0", "unsupported"], - ["1.99.0", "graceful"], - ["1.14.19", "graceful"], - ["1.14.18", "broken"], - ] as const) { - for (const t3CodeVersion of [V2_RELEASE, "0.0.46-preview.20261002.2598"]) { - const advisory = resolveProviderCompatibility( - ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, - opencode, - version, - t3CodeVersion, - ); - assert.strictEqual(advisory?.status, expected, `OpenCode ${version} on ${t3CodeVersion}`); - assert.strictEqual(advisory?.recommendedRange, ">=2.0.18"); - } - } - const ready = applyProviderCompatibility( - { ...provider, driver: opencode, version: "1.18.33", status: "ready", message: undefined }, - undefined, - ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, - ); - assert.strictEqual(ready.status, "ready"); - assert.strictEqual( - ready.compatibilityAdvisory?.status, - resolveProviderCompatibility( - ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, - opencode, - "1.18.33", - )?.status, - ); - }); - it("compares Cursor build dates without treating semver prereleases as stable", () => { const cursor = ProviderDriverKind.make("cursor"); const cursorPolicy: ProviderCompatibilityPolicy = { @@ -162,26 +107,6 @@ describe("provider compatibility", () => { ); }); - it("compares Muse versions by their release, ignoring the revision suffix", () => { - const muse = ProviderDriverKind.make("muse"); - for (const [version, expected] of [ - ["1.4.3-R5018.1", "supported"], - ["1.4.2-R4684", "supported"], - ["1.4.1-R4100.2", "unknown"], - ["1.4.3-beta.1", "unknown"], - ] as const) { - assert.strictEqual( - resolveProviderCompatibility( - ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, - muse, - version, - V2_RELEASE, - )?.status, - expected, - ); - } - }); - it("recognizes Antigravity semver release tags while keeping dated candidates unknown", () => { const antigravity = ProviderDriverKind.make("antigravity"); const taggedPolicy = { @@ -232,25 +157,6 @@ describe("provider compatibility", () => { } }); - it("supports every driver without inventing policies for uncovered adapters", () => { - for (const kind of [ - "codex", - "claudeAgent", - "cursor", - "grok", - "opencode", - "antigravity", - "customDriver", - ]) { - const adapter = ProviderDriverKind.make(kind); - assert.strictEqual( - resolveProviderCompatibility([{ ...policy, driver: adapter }], adapter, "2.0.0")?.status, - "supported", - ); - assert.isUndefined(resolveProviderCompatibility([], adapter, "2.0.0")); - } - }); - it("relaxes remote policy without losing probe errors, and falls back when a policy is omitted", () => { const broken = applyProviderCompatibility(provider, [], [policy]); assert.strictEqual(broken.compatibilityAdvisory?.status, "broken"); @@ -350,6 +256,7 @@ it.effect("a remote policy refresh preserves a newer health result on the regist ), ); const layerDependencies = Layer.mergeAll( + ProviderLatestVersions.layer, Layer.succeed(ModelManifest.ModelManifest, { current: Ref.get(manifest), refresh, @@ -391,32 +298,3 @@ it.effect("a remote policy refresh preserves a newer health result on the regist }).pipe(Effect.provide(ProviderRegistry.layer.pipe(Layer.provide(layerDependencies)))); }).pipe(Effect.scoped), ); - -it("recomputes latest-version compatibility without losing the underlying update advisory", () => { - const snapshot: ServerProvider = { - ...provider, - version: "2.0.0", - versionAdvisory: { - status: "behind_latest", - currentVersion: "2.0.0", - latestVersion: "4.0.0", - canUpdate: true, - updateCommand: "npm install -g fixture@latest", - checkedAt: provider.checkedAt, - message: null, - }, - }; - const blockedPolicy: ProviderCompatibilityPolicy = { - ...policy, - ranges: [...policy.ranges, { range: ">=3.0.0", status: "broken" }], - }; - const blocked = applyProviderCompatibility(snapshot, [blockedPolicy], []); - assert.strictEqual(blocked.compatibilityAdvisory?.latestVersionStatus, "broken"); - const relaxed = applyProviderCompatibility( - blocked, - [{ ...policy, ranges: [{ range: ">=2.0.0", status: "supported" }] }], - [], - ); - assert.strictEqual(relaxed.compatibilityAdvisory?.latestVersionStatus, "supported"); - assert.deepStrictEqual(relaxed.versionAdvisory, snapshot.versionAdvisory); -}); diff --git a/apps/server/src/provider/providerCompatibility.ts b/apps/server/src/provider/providerCompatibility.ts index a7a63324e751..038899a116d0 100644 --- a/apps/server/src/provider/providerCompatibility.ts +++ b/apps/server/src/provider/providerCompatibility.ts @@ -115,11 +115,20 @@ export function applyProviderCompatibility( ? (resolveProviderCompatibility(policies, snapshot.driver, latestVersion) ?? resolveProviderCompatibility(fallback, snapshot.driver, latestVersion)) : undefined; + const updateCommand = snapshot.versionAdvisory?.updateCommand; + // The maintenance resolver emits this command only for confirmed Homebrew installs. + const needsHomebrewRefresh = + updateCommand?.startsWith("brew upgrade ") && + (advisory?.status === "unsupported" || advisory?.status === "broken") && + (latestAdvisory?.status === "unsupported" || latestAdvisory?.status === "broken"); return advisory ? { ...base, compatibilityAdvisory: { ...advisory, + message: needsHomebrewRefresh + ? `${advisory.message} Homebrew's local package metadata may be stale. Run brew update && ${updateCommand} on this environment's host, then refresh provider status.` + : advisory.message, ...(latestAdvisory ? { latestVersionStatus: latestAdvisory.status } : {}), }, } diff --git a/apps/server/src/provider/providerInstallation.test.ts b/apps/server/src/provider/providerInstallation.test.ts index af72c11a19f0..32d417a1d43d 100644 --- a/apps/server/src/provider/providerInstallation.test.ts +++ b/apps/server/src/provider/providerInstallation.test.ts @@ -123,27 +123,6 @@ const makeHarness = Effect.fn("providerInstallation.test.makeHarness")(function* }); describe("provider installation routing", () => { - it.effect("allows explicit installation while the provider is disabled", () => - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.router.start({ instanceId }); - assert.deepEqual(harness.calls, ["start"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("rejects another driver and unknown instances before installation", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ instance: instance(ProviderDriverKind.make("codex")) }); - const wrongDriver = yield* Effect.flip(harness.router.start({ instanceId })); - const missing = yield* Effect.flip( - harness.router.start({ instanceId: ProviderInstanceId.make("missing") }), - ); - assert.equal(wrongDriver._tag, "ProviderSetupError"); - assert.equal(missing._tag, "ProviderSetupError"); - assert.deepEqual(harness.calls, []); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("keeps external installs manual without hiding shared install status", () => Effect.gen(function* () { const harness = yield* makeHarness({ @@ -166,51 +145,6 @@ describe("provider installation routing", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("routes a managed Codex instance through its own installer and refreshes removal", () => - Effect.gen(function* () { - const codexId = ProviderInstanceId.make("codex"); - const harness = yield* makeHarness({ - instance: instance(ProviderDriverKind.make("codex"), codexId), - settings: { - providerInstances: { - [ProviderInstanceId.make("codex")]: { - driver: ProviderDriverKind.make("codex"), - config: { setupMode: "managed" }, - }, - }, - }, - }); - assert.equal((yield* harness.router.start({ instanceId: codexId })).driver, "codex"); - yield* harness.router.cancel({ instanceId: codexId, operationId: "operation" }); - const observed = yield* Stream.runCollect(harness.router.subscribe({ instanceId: codexId })); - assert.equal(Array.from(observed)[0]?.driver, "codex"); - yield* harness.router.remove({ instanceId: codexId }); - assert.deepEqual(harness.calls, ["codex-start", "codex-cancel", "codex-remove", "refresh"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("keeps native Codex installation outside managed setup", () => - Effect.gen(function* () { - const codexId = ProviderInstanceId.make("codex"); - const harness = yield* makeHarness({ - instance: instance(ProviderDriverKind.make("codex"), codexId), - settings: { - providerInstances: { - [ProviderInstanceId.make("codex")]: { - driver: ProviderDriverKind.make("codex"), - config: { setupMode: "existing" }, - }, - }, - }, - }); - assert.include( - (yield* Effect.flip(harness.router.start({ instanceId: codexId }))).detail, - "Choose managed setup", - ); - assert.deepEqual(harness.calls, []); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("protects another instance's binary found through its own PATH", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -238,25 +172,4 @@ describe("provider installation routing", () => { assert.deepEqual(harness.calls, ["remove", "refresh"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - - it.effect("keeps only the configured path when another instance's binary is missing", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-install-route-" }); - const binaryPath = path.join(directory, "missing"); - const harness = yield* makeHarness({ - settings: { - providerInstances: { - [ProviderInstanceId.make("antigravity-work")]: { - driver, - config: { binaryPath }, - }, - }, - }, - }); - yield* harness.router.remove({ instanceId }); - assert.deepEqual(harness.protectedPaths(), [binaryPath]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); }); diff --git a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts index f2fc03659be0..c7db6bdf76aa 100644 --- a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts +++ b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts @@ -35,13 +35,13 @@ export const makeProviderMaintenanceCommandCoordinator = Effect.fn( return next; }); - const withCommandLock: ProviderMaintenanceCommandCoordinatorShape["withCommandLock"] = ({ - targetKey, - lockKey, - onQueued, - run, - }) => - Effect.gen(function* () { + const withCommandLock: ProviderMaintenanceCommandCoordinatorShape["withCommandLock"] = + Effect.fn("providerMaintenanceCommandCoordinator.withCommandLock")(function* ({ + targetKey, + lockKey, + onQueued, + run, + }) { const acquired = yield* acquireTarget(targetKey); if (!acquired) { return yield* Effect.fail(input.makeAlreadyRunningError(targetKey)); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index c33802fe252e..43808ff37df8 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -7,8 +7,6 @@ import { } from "@t3tools/contracts"; import { ServerProviderUpdateError } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -17,7 +15,6 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -28,6 +25,7 @@ import * as ModelManifest from "./ModelManifest.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import { + enrichProviderSnapshotWithVersionAdvisory, makeProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilities, } from "@t3tools/provider-core/server/maintenanceResolver"; @@ -197,6 +195,7 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), + refreshAfterUpdate: () => Ref.get(providersRef), refreshWorkspaceSnapshot: () => Ref.get(providersRef), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), @@ -282,31 +281,6 @@ describe("providerMaintenanceRunner", () => { ); }); - it.effect("reports unchanged when the updater exits 0 but the provider is gone", () => { - return Effect.gen(function* () { - const { registry, providersRef } = yield* makeRegistry(baseProvider); - const updater = yield* makeTestRunner({ - ...registry, - refreshInstance: () => - Ref.updateAndGet(providersRef, (providers) => - providers.map((provider) => ({ ...provider, installed: false, version: null })), - ), - }); - - const result = yield* updater.updateProvider(CODEX_DRIVER); - assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); - assert.match(result.providers[0]?.updateState?.message ?? "", /could not verify/); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner(() => ({ stdout: "updated" })), - ), - ), - ); - }); - it.effect( "keeps a successful update when the binary is present but its version is unreadable", () => { @@ -314,7 +288,7 @@ describe("providerMaintenanceRunner", () => { const { registry, providersRef } = yield* makeRegistry(baseNativeCliProvider); const updater = yield* makeTestRunner({ ...registry, - refreshInstance: () => + refreshAfterUpdate: () => Ref.updateAndGet(providersRef, (providers) => providers.map((provider) => ({ ...provider, installed: true, version: null })), ), @@ -409,78 +383,6 @@ describe("providerMaintenanceRunner", () => { ); }); - it.effect("reports the installer's latest output line while the update runs", () => { - const exited = Deferred.makeUnsafe(); - return Effect.gen(function* () { - const { registry, updateStatesRef } = yield* makeRegistry(baseNativeCliProvider); - const updater = yield* makeTestRunner(registry); - const runningMessages = Ref.get(updateStatesRef).pipe( - Effect.map((states) => - states.filter((state) => state.status === "running").map((state) => state.message), - ), - ); - - const fiber = yield* updater.updateProvider(NATIVE_CLI_DRIVER).pipe(Effect.forkChild); - yield* TestClock.adjust(Duration.seconds(1)); - assert.deepStrictEqual(yield* runningMessages, [ - "Checking for the latest version", - "Running native-agent update", - "80%", - ]); - - yield* Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)); - const result = yield* Fiber.join(fiber); - assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner(() => ({ - stdout: "Checking for updates\nDownloading 2.1.293\n\u001b[32m 40%\u001b[0m\r 80%", - exitCode: Deferred.await(exited), - })), - ), - ), - ); - }); - - it.effect("installs the advertised version when pnpm owns the global install", () => { - const calls: Array<{ command: string; args: ReadonlyArray }> = []; - return Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); - const updater = yield* makeTestRunner({ - ...registry, - getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => - Effect.succeed( - makeProviderMaintenanceCapabilities({ - provider, - packageName: "@openai/codex", - updateExecutable: "pnpm", - updateArgs: ["add", "-g", "@openai/codex@latest"], - updateLockKey: "pnpm-global", - }), - ), - }); - - yield* updater.updateProvider(CODEX_DRIVER); - assert.deepStrictEqual(calls, [ - { command: "pnpm", args: ["add", "-g", "@openai/codex@9.9.9"] }, - ]); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerNonWindowsPlatform, - layerLatestVersionHttpClient("9.9.9"), - layerMockSpawner((command, args) => { - calls.push({ command, args }); - return { stdout: "updated" }; - }), - ), - ), - ); - }); - it("splits installer output into clean status lines", () => { assert.deepStrictEqual(ProviderMaintenanceRunner.splitOutputLines("Down", "loading\r\n 5"), { lines: ["Downloading"], @@ -543,177 +445,52 @@ describe("providerMaintenanceRunner", () => { ); }); - it.effect("uses the resolved provider capabilities when choosing the update executable", () => { - const calls: Array<{ command: string; args: ReadonlyArray }> = []; - return Effect.gen(function* () { - const { registry } = yield* makeRegistry({ - ...baseProvider, - versionAdvisory: { - status: "behind_latest", - currentVersion: "2.0.14", - latestVersion: "2.1.123", - updateCommand: "bun i -g @anthropic-ai/claude-code@latest", - canUpdate: true, - checkedAt: "2026-04-30T12:00:00.000Z", - message: "Update available.", - }, - }); - const updater = yield* makeTestRunner({ - ...registry, - getProviderMaintenanceCapabilitiesForInstance: () => - Effect.succeed( - makeProviderMaintenanceCapabilities({ - provider: CODEX_DRIVER, - packageName: "@openai/codex", - updateExecutable: "bun", - updateArgs: ["i", "-g", "@openai/codex@latest"], - updateLockKey: "bun-global", - }), - ), - }); - - yield* updater.updateProvider(CODEX_DRIVER); - assert.deepStrictEqual(calls, [ - { - command: "bun", - args: ["i", "-g", "@openai/codex@latest"], - }, - ]); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner((command, args) => { - calls.push({ command, args }); - return { stdout: "updated" }; - }), - ), - ), - ); - }); - - it.effect( - "runs update commands through Effect ChildProcess when no test runner is injected", - () => { - const calls: Array<{ command: string; args: ReadonlyArray }> = []; - return Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); - const runner = yield* makeTestRunner(registry); + it.effect("records command failure output in provider update state", () => + Effect.gen(function* () { + const { registry } = yield* makeRegistry(); + const updater = yield* makeTestRunner(registry); - const result = yield* runner.updateProvider(CODEX_DRIVER); + const result = yield* updater.updateProvider(CODEX_DRIVER); + const updateState = result.providers[0]?.updateState; - assert.deepStrictEqual(calls, [ - { - command: "npm", - args: ["install", "-g", "@openai/codex@latest"], - }, - ]); - assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner((command, args) => { - calls.push({ command, args }); - return { stdout: "updated" }; - }), - ), - ), + assert.strictEqual(updateState?.status, "failed"); + assert.strictEqual( + updateState?.message, + "Update command exited with code 1: Error: Could not detect the Codex installation method.", ); - }, - ); - - it.effect("updates a single provider instance without touching sibling instances", () => { - const calls: Array<{ command: string; args: ReadonlyArray }> = []; - return Effect.gen(function* () { - const personalInstanceId = ProviderInstanceId.make("codex_personal"); - const workInstanceId = ProviderInstanceId.make("codex_work"); - const refreshedInstanceIds: Array = []; - const { registry } = yield* makeRegistry([ - { - ...baseProvider, - instanceId: personalInstanceId, - version: "0.124.0-alpha.3", - }, - { - ...baseProvider, - instanceId: workInstanceId, - version: "0.124.0-alpha.3", - }, - ]); - const updater = yield* makeTestRunner({ - ...registry, - getProviderMaintenanceCapabilitiesForInstance: (instanceId, provider) => - Effect.succeed( - makeProviderMaintenanceCapabilities({ - provider, - packageName: "@openai/codex-instance-test", - updateExecutable: "vp", - updateArgs: ["i", "-g", "@openai/codex"], - updateLockKey: "vite-plus-global", - }), - ).pipe( - Effect.tap(() => Effect.sync(() => assert.strictEqual(instanceId, personalInstanceId))), - ), - refreshInstance: (instanceId) => - registry.refreshInstance(instanceId).pipe( - Effect.tap(() => - Effect.sync(() => { - refreshedInstanceIds.push(instanceId); - }), - ), - ), - }); - - const result = yield* updater.updateProvider({ - provider: CODEX_DRIVER, - instanceId: personalInstanceId, - }); - - assert.deepStrictEqual(calls, [ - { - command: "vp", - args: ["i", "-g", "@openai/codex"], - }, - ]); - assert.deepStrictEqual(refreshedInstanceIds, [personalInstanceId]); - assert.strictEqual(result.providers[0]?.instanceId, personalInstanceId); - assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); - assert.strictEqual(result.providers[1]?.instanceId, workInstanceId); - assert.strictEqual(result.providers[1]?.updateState, undefined); + assert.include(updateState?.output ?? "", "checking install"); }).pipe( Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.124.0-alpha.3"), - layerMockSpawner((command, args) => { - calls.push({ command, args }); - return { stdout: "updated" }; - }), + layerLatestVersionHttpClient("0.0.0"), + layerMockSpawner(() => ({ + stdout: "checking install\n", + stderr: "Error: Could not detect the Codex installation method.\n\n", + code: 1, + })), ), ), - ); - }); + ), + ); - it.effect("records command failure output in provider update state", () => + it.effect("keeps the exit code message when a failed command prints nothing", () => Effect.gen(function* () { const { registry } = yield* makeRegistry(); const updater = yield* makeTestRunner(registry); const result = yield* updater.updateProvider(CODEX_DRIVER); - const updateState = result.providers[0]?.updateState; - assert.strictEqual(updateState?.status, "failed"); - assert.strictEqual(updateState?.message, "Update command exited with code 1."); - assert.include(updateState?.output ?? "", "permission denied"); + assert.strictEqual( + result.providers[0]?.updateState?.message, + "Update command exited with code 1.", + ); }).pipe( Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner(() => ({ stderr: "permission denied", code: 1 })), + layerMockSpawner(() => ({ code: 1 })), ), ), ), @@ -792,122 +569,6 @@ describe("providerMaintenanceRunner", () => { ); }); - it.effect("serializes different providers that share the same update lock key", () => { - const firstStartedLatch: { resolve: () => void } = { resolve: () => {} }; - const releaseFirstLatch: { resolve: () => void } = { resolve: () => {} }; - const firstStarted = new Promise((resolve) => { - firstStartedLatch.resolve = resolve; - }); - const releaseFirst = new Promise((resolve) => { - releaseFirstLatch.resolve = resolve; - }); - const calls: Array = []; - return Effect.gen(function* () { - const { registry } = yield* makeRegistry([baseProvider, baseOpenCodeProvider]); - const updater = yield* makeTestRunner({ - ...registry, - getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => - Effect.succeed( - makeProviderMaintenanceCapabilities({ - provider, - packageName: provider === OPENCODE_DRIVER ? "opencode-ai" : "@openai/codex", - updateExecutable: "npm", - updateArgs: - provider === OPENCODE_DRIVER - ? ["install", "-g", "opencode-ai@latest"] - : ["install", "-g", "@openai/codex@latest"], - updateLockKey: "npm-global", - }), - ), - }); - - const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped); - yield* Effect.promise(() => firstStarted); - - const second = yield* updater.updateProvider(OPENCODE_DRIVER).pipe(Effect.forkScoped); - let providersWhileQueued: ReadonlyArray = []; - for (let attempt = 0; attempt < 20; attempt += 1) { - providersWhileQueued = yield* registry.getProviders; - const queuedStatus = providersWhileQueued.find( - (provider) => provider.instanceId === OPENCODE_INSTANCE_ID, - )?.updateState?.status; - if (queuedStatus === "queued") { - break; - } - yield* Effect.yieldNow; - } - assert.deepStrictEqual(calls, ["install -g @openai/codex@latest"]); - assert.strictEqual( - providersWhileQueued.find((provider) => provider.instanceId === OPENCODE_INSTANCE_ID) - ?.updateState?.status, - "queued", - ); - - releaseFirstLatch.resolve(); - yield* Fiber.join(first); - yield* Fiber.join(second); - assert.deepStrictEqual(calls, [ - "install -g @openai/codex@latest", - "install -g opencode-ai@latest", - ]); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerNonWindowsPlatform, - layerLatestVersionHttpClient("2.0.0"), - layerMockSpawner((_command, args) => { - calls.push(args.join(" ")); - if (calls.length === 1) { - firstStartedLatch.resolve(); - return { - stdout: "updated", - exitCode: Effect.promise(() => releaseFirst).pipe( - Effect.as(ChildProcessSpawner.ExitCode(0)), - ), - }; - } - return { stdout: "updated" }; - }), - ), - ), - ); - }); - - it.effect("accepts arbitrary driver-provided update lock keys", () => { - const calls: Array = []; - return Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); - const updater = yield* makeTestRunner({ - ...registry, - getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => - Effect.succeed( - makeProviderMaintenanceCapabilities({ - provider, - packageName: "@openai/codex", - updateExecutable: "npm", - updateArgs: ["install", "-g", "@openai/codex@latest"], - updateLockKey: "unknown-lock-key", - }), - ), - }); - - const result = yield* updater.updateProvider(CODEX_DRIVER); - assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); - assert.deepStrictEqual(calls, ["install -g @openai/codex@latest"]); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner((_command, args) => { - calls.push(args.join(" ")); - return { stdout: "updated" }; - }), - ), - ), - ); - }); - it.effect( "releases the running-provider marker when interrupted after queuing but before the lock run starts", () => @@ -1016,6 +677,180 @@ describe("providerMaintenanceRunner", () => { }); }); +it.effect.each([ + { + mode: "default", + version: "1.14.19", + installation: { installedVersion: "1.14.20" }, + status: "succeeded", + }, + { + mode: "targeted", + version: "1.14.19", + installation: { installedVersion: "1.14.20" }, + status: "succeeded", + }, + { + mode: "unknown", + version: "1.14.20", + installation: { installedVersion: null }, + status: "unchanged", + }, + { mode: "legacy", version: "1.14.20", installation: {}, status: "succeeded" }, +] as const)( + "verifies the $mode OpenCode update using the refreshed installation", + ({ mode, version, installation, status }) => { + const calls: string[] = []; + const capabilities = lifecycleFor(OPENCODE_DRIVER); + const pinnedCapabilities = { + ...capabilities, + update: { ...capabilities.update!, lockKey: "npm-global:/fixture" }, + }; + const manifest: ModelManifest.ModelManifestData = { + version: 1, + currentModels: {}, + compatibility: [ + { + driver: OPENCODE_DRIVER, + t3CodeRange: ">=0.0.42", + recommendedVersion: "1.14.20", + ranges: [{ range: "=1.14.20", status: "supported" }], + }, + ], + }; + return Effect.gen(function* () { + const { registry, providersRef } = yield* makeRegistry({ ...baseOpenCodeProvider, version }); + const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; + const updater = yield* makeTestRunner( + { + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities), + refreshAfterUpdate: () => + Effect.gen(function* () { + assert.strictEqual(calls.length, 1); + const refreshed = yield* enrichProviderSnapshotWithVersionAdvisory( + { ...baseOpenCodeProvider, version, ...installation }, + pinnedCapabilities, + ).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService( + ProviderLatestVersions.ProviderLatestVersions, + latestVersions, + ), + ); + return yield* Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => ({ ...provider, ...refreshed })), + ); + }), + }, + manifest, + ); + + const result = yield* updater.updateProvider({ + provider: OPENCODE_DRIVER, + ...(mode === "default" ? {} : { targetVersion: "1.14.20" }), + }); + + assert.deepStrictEqual(calls, [ + `npm install -g opencode-ai@${mode === "default" ? "latest" : "1.14.20"}`, + ]); + const provider = result.providers[0]; + assert.ok(provider); + assert.strictEqual(provider.updateState?.status, status); + assert.strictEqual(provider.version, version); + assert.strictEqual("installedVersion" in provider, mode !== "legacy"); + assert.strictEqual( + provider.versionAdvisory?.status, + mode === "unknown" ? "unknown" : "current", + ); + assert.strictEqual( + provider.versionAdvisory?.currentVersion, + mode === "unknown" ? null : "1.14.20", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + ProviderLatestVersions.layer, + layerLatestVersionHttpClient("1.14.20"), + layerMockSpawner((command, args) => { + calls.push([command, ...args].join(" ")); + return { stdout: "installed" }; + }), + ), + ), + ); + }, +); + +it.effect( + "uses fresh installer versions for post-update verification and compatibility checks", + () => { + const calls: string[] = []; + let latest = "1.0.0"; + const manifest: ModelManifest.ModelManifestData = { + version: 1, + currentModels: {}, + compatibility: [ + { + driver: "codex", + t3CodeRange: ">=0.0.42", + ranges: [ + { range: "<=2.0.0", status: "supported" }, + { range: ">2.0.0", status: "broken" }, + ], + }, + ], + }; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry({ ...baseProvider, version: "1.0.0" }); + const capabilities = makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "/opt/homebrew/bin/brew", + updateArgs: ["upgrade", "--cask", "codex"], + updateLockKey: "homebrew", + latestVersion: null, + lookupLatestVersion: Effect.sync(() => latest), + }); + const updater = yield* makeTestRunner( + { + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(capabilities), + }, + manifest, + ); + + const unchanged = yield* updater.updateProvider(CODEX_DRIVER); + assert.strictEqual(unchanged.providers[0]?.updateState?.status, "unchanged"); + assert.match(unchanged.providers[0]?.updateState?.message ?? "", /outdated provider version/); + assert.deepStrictEqual(calls, ["upgrade --cask codex"]); + + latest = "3.0.0"; + const refused = yield* updater.updateProvider(CODEX_DRIVER); + assert.strictEqual(refused.providers[0]?.updateState?.status, "failed"); + assert.match(refused.providers[0]?.updateState?.message ?? "", /incompatible/); + assert.deepStrictEqual(calls, ["upgrade --cask codex"]); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Homebrew's latest should not come from npm")), + ), + layerMockSpawner((_command, args) => { + calls.push(args.join(" ")); + latest = "2.0.0"; + return { stdout: "upgraded" }; + }), + ), + ), + ); + }, +); + it.effect("refuses incompatible latest versions and unapproved or unpinnable targets", () => { const calls: string[] = []; const manifest: ModelManifest.ModelManifestData = { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 481d66d7e11d..43d8b1e3916a 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -30,6 +30,7 @@ import * as ProviderRegistry from "./ProviderRegistry.ts"; import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; import { enrichProviderSnapshotWithVersionAdvisory, + invalidateLatestProviderVersion, makeTargetedProviderUpdateAction, resolveLatestProviderUpdateAction, resolveLatestProviderVersion, @@ -241,14 +242,30 @@ function commandOutput(result: ProviderMaintenanceCommandResult): string | null return truncateText(output, UPDATE_OUTPUT_MAX_BYTES); } +// Updaters print their reason last, usually on stderr. +function lastOutputLine(result: ProviderMaintenanceCommandResult): string | null { + for (const output of [result.stderr, result.stdout]) { + const line = output + .split(/\r\n|\r|\n/) + .map(toProgressLine) + .findLast(Boolean); + if (line) { + return line; + } + } + return null; +} + function failureMessage(result: ProviderMaintenanceCommandResult): string { if (result.timedOut) { return "Update timed out."; } - if (result.exitCode !== null && result.exitCode !== 0) { - return `Update command exited with code ${result.exitCode}.`; - } - return "Update command failed."; + const summary = + result.exitCode !== null && result.exitCode !== 0 + ? `Update command exited with code ${result.exitCode}` + : "Update command failed"; + const reason = lastOutputLine(result); + return reason ? `${summary}: ${reason}` : `${summary}.`; } function isOutdatedProvider(provider: ServerProvider | undefined): boolean { @@ -306,28 +323,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { maintenanceCapabilities: ProviderMaintenanceCapabilities, instanceId: ProviderInstanceId, ): Effect.Effect => - providerRegistry.getProviders.pipe( - Effect.map((providers) => { - const instanceIds: Array = []; - for (const candidate of providers) { - if (candidate.driver === provider && candidate.instanceId === instanceId) { - instanceIds.push(candidate.instanceId); - } - } - return instanceIds; - }), - Effect.flatMap((instanceIds) => - instanceIds.length === 0 - ? providerRegistry.refreshInstance(instanceId) - : Effect.forEach( - instanceIds, - (instanceId) => providerRegistry.refreshInstance(instanceId), - { - concurrency: "unbounded", - discard: true, - }, - ).pipe(Effect.andThen(providerRegistry.getProviders)), - ), + providerRegistry.refreshAfterUpdate(provider, instanceId).pipe( Effect.flatMap((providers) => { const refreshedProviders = providers.filter( (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, @@ -449,9 +445,11 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { } const manifest = yield* manifestService.current; + // Check compatibility against what an unpinned command would install now. const candidateVersion = targetVersion ?? - (yield* resolveLatestProviderVersion(fresh).pipe( + (yield* invalidateLatestProviderVersion(fresh).pipe( + Effect.andThen(resolveLatestProviderVersion(fresh)), Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService( ProviderLatestVersions.ProviderLatestVersions, @@ -492,6 +490,13 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const result = yield* runMaintenanceCommand(command, setRunningMessage); const finishedAt = yield* nowIso; if (result.timedOut || result.exitCode !== 0) { + yield* Effect.logWarning("Provider update command failed", { + provider, + command: describeCommand(command), + exitCode: result.exitCode, + timedOut: result.timedOut, + output: commandOutput(result), + }); return yield* finish( makeUpdateState({ status: "failed", @@ -504,12 +509,14 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { } yield* setRunningMessage("Verifying the installed version"); - // Homebrew's "latest" moves once the upgrade lands; read it again. const verified = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, provider, { fresh: true }, ); + yield* invalidateLatestProviderVersion(verified).pipe( + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), + ); const { verifiedProviders } = yield* verifyRefreshedProvider( provider, verified, @@ -519,12 +526,20 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { // fail transiently on a healthy binary. const couldNotVerify = verifiedProviders.length === 0 || - verifiedProviders.some( - (verifiedProvider) => + verifiedProviders.some((verifiedProvider) => { + const installedVersion = + verifiedProvider.installedVersion !== undefined + ? verifiedProvider.installedVersion + : verifiedProvider.version; + return ( !isStillInstalled(verifiedProvider) || + (verifiedProvider.status === "error" && + (verifiedProvider.auth.status !== "unauthenticated" || + !verifiedProvider.version)) || (targetVersion !== undefined && - verifiedProvider.version?.replace(/^v/, "") !== targetVersion), - ); + installedVersion?.replace(/^v/, "") !== targetVersion) + ); + }); const stillOutdated = targetVersion === undefined && verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index cd7bfff273f8..79cb0bfbf5fd 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -7,22 +7,11 @@ import { } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Logger from "effect/Logger"; -import { - hydrateCachedProvider, - isCachedProviderCorrelated, - readProviderStatusCache, - resolveProviderStatusCachePath, - writeProviderStatusCache, -} from "./providerStatusCache.ts"; +import { hydrateCachedProvider, isCachedProviderCorrelated } from "./providerStatusCache.ts"; const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); const CODEX_DRIVER = ProviderDriverKind.make("codex"); -const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); -const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); const makeProvider = ( provider: ProviderDriverKind, @@ -43,84 +32,6 @@ const makeProvider = ( }); it.layer(NodeServices.layer)("providerStatusCache", (it) => { - it.effect("logs structural diagnostics without retaining invalid cache contents", () => { - const messages: Array = []; - const logger = Logger.make((options) => { - if (Array.isArray(options.message)) { - messages.push(...options.message); - } else { - messages.push(options.message); - } - }); - - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-invalid-" }); - const cachePath = `${tempDir}/provider.json`; - const secretCacheValue = "secret-cache-value"; - yield* fs.writeFileString(cachePath, `{ "token": "${secretCacheValue}" }`); - - const result = yield* readProviderStatusCache(cachePath); - - assert.strictEqual(result, undefined); - const failure = messages.find( - (message): message is Record => - typeof message === "object" && message !== null && "path" in message, - ); - assert.exists(failure); - assert.strictEqual(failure.path, cachePath); - assert.strictEqual(typeof failure.errorTag, "string"); - assert.ok(!("cause" in failure)); - assert.ok(!("issues" in failure)); - assert.ok(!Object.values(failure).map(String).join("\n").includes(secretCacheValue)); - }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); - }); - - it.effect("writes and reads provider status snapshots", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-" }); - const codexProvider = makeProvider(CODEX_DRIVER); - const claudeProvider = makeProvider(CLAUDE_AGENT_DRIVER, { - status: "warning", - auth: { status: "unknown" }, - }); - const openCodeProvider = makeProvider(OPENCODE_DRIVER, { - status: "warning", - auth: { status: "unknown", type: "opencode" }, - }); - const codexPath = yield* resolveProviderStatusCachePath({ - cacheDir: tempDir, - instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("codex")), - }); - const claudePath = yield* resolveProviderStatusCachePath({ - cacheDir: tempDir, - instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("claudeAgent")), - }); - const openCodePath = yield* resolveProviderStatusCachePath({ - cacheDir: tempDir, - instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("opencode")), - }); - - yield* writeProviderStatusCache({ - filePath: codexPath, - provider: codexProvider, - }); - yield* writeProviderStatusCache({ - filePath: claudePath, - provider: claudeProvider, - }); - yield* writeProviderStatusCache({ - filePath: openCodePath, - provider: openCodeProvider, - }); - - assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider); - assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider); - assert.deepStrictEqual(yield* readProviderStatusCache(openCodePath), openCodeProvider); - }), - ); - it("hydrates cached provider status while preserving current settings-derived models", () => { const cachedCodex = makeProvider(CODEX_DRIVER, { checkedAt: "2026-04-10T12:00:00.000Z", diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index d2e5a70084de..486b7fd41046 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -108,8 +108,8 @@ export const resolveProviderStatusCachePath = Effect.fn("resolveProviderStatusCa }, ); -export const readProviderStatusCache = (filePath: string) => - Effect.gen(function* () { +export const readProviderStatusCache = Effect.fn("providerStatusCache.readProviderStatusCache")( + function* (filePath: string) { const fs = yield* FileSystem.FileSystem; const exists = yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)); if (!exists) { @@ -132,7 +132,8 @@ export const readProviderStatusCache = (filePath: string) => onSuccess: Effect.succeed, }), ); - }); + }, +); export const writeProviderStatusCache = (input: { readonly filePath: string; diff --git a/apps/server/src/provider/resetCreditCoordinator.ts b/apps/server/src/provider/resetCreditCoordinator.ts index 95e24cbdc656..45532eeb0550 100644 --- a/apps/server/src/provider/resetCreditCoordinator.ts +++ b/apps/server/src/provider/resetCreditCoordinator.ts @@ -58,24 +58,25 @@ export const make = Effect.gen(function* () { }); }); - const redeem: ResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume, isSettled) => - Effect.gen(function* () { - const state = yield* stateFor(accountKey); - return yield* state.lock.withPermits(1)( - Effect.gen(function* () { - const existing = yield* Ref.get(state.pendingKey); - const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4()); - yield* Ref.set(state.pendingKey, idempotencyKey); - const outcome = yield* consume(idempotencyKey).pipe( - Effect.tapError((error) => - isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void, - ), - ); - yield* Ref.set(state.pendingKey, null); - return outcome; - }), - ); - }); + const redeem: ResetCreditCoordinator["Service"]["redeem"] = Effect.fn( + "resetCreditCoordinator.redeem", + )(function* (accountKey, consume, isSettled) { + const state = yield* stateFor(accountKey); + return yield* state.lock.withPermits(1)( + Effect.gen(function* () { + const existing = yield* Ref.get(state.pendingKey); + const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4()); + yield* Ref.set(state.pendingKey, idempotencyKey); + const outcome = yield* consume(idempotencyKey).pipe( + Effect.tapError((error) => + isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void, + ), + ); + yield* Ref.set(state.pendingKey, null); + return outcome; + }), + ); + }); return { redeem } satisfies ResetCreditCoordinator["Service"]; }); diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 41b5c9b9ca35..358d49afe45c 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -11,6 +11,7 @@ const makeProviderRegistryMock = ( getProviders: Effect.succeed(providers), refresh: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), + refreshAfterUpdate: () => Effect.succeed(providers), refreshWorkspaceSnapshot: () => Effect.succeed(providers), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), diff --git a/apps/server/src/provider/testing/ClaudeCapabilitiesProbe.fixture.mjs b/apps/server/src/provider/testing/ClaudeCapabilitiesProbe.fixture.mjs deleted file mode 100755 index f85dba5e6b5f..000000000000 --- a/apps/server/src/provider/testing/ClaudeCapabilitiesProbe.fixture.mjs +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env node -import * as NodeFS from "node:fs"; -import * as NodeReadline from "node:readline"; -const args = process.argv.slice(2); -const mcpConfigIndex = args.indexOf("--mcp-config"); -const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined; -let mcpConfig; -if (rawMcpConfig) { - const contents = NodeFS.existsSync(rawMcpConfig) - ? NodeFS.readFileSync(rawMcpConfig, "utf8") - : rawMcpConfig; - try { - mcpConfig = JSON.parse(contents); - } catch { - mcpConfig = contents; - } -} -NodeFS.writeFileSync( - process.env.T3_PROBE_INVOCATION_PATH, - JSON.stringify({ - args, - cwd: process.cwd(), - connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS, - mcpConfig, - }), -); -const lines = NodeReadline.createInterface({ input: process.stdin }); -lines.on("line", (line) => { - const message = JSON.parse(line); - if (message.type !== "control_request") return; - const reply = (response) => - process.stdout.write( - JSON.stringify({ - type: "control_response", - response: { subtype: "success", request_id: message.request_id, response }, - }) + "\n", - ); - if (message.request?.subtype === "initialize") { - reply({ - commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], - agents: [], - output_style: "default", - available_output_styles: ["default"], - models: [], - account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, - }); - } - if (message.request?.subtype === "get_usage") { - reply({ - session: {}, - subscription_type: "pro", - rate_limits_available: true, - rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, - behaviors: null, - }); - } -}); -// Stay alive for follow-up control requests, but never outlive the -// parent: the probe aborts the SDK without awaiting the child, so an -// unconditional interval would strand this process until reboot. -const keepAlive = setInterval(() => {}, 1_000); -lines.on("close", () => { - clearInterval(keepAlive); - process.exit(0); -}); diff --git a/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs b/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs index 3a864d524e80..e30acd7d6db3 100755 --- a/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs +++ b/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs @@ -10,13 +10,34 @@ if (process.argv.includes("--version")) { process.stdout.write("claude 2.1.219\n"); process.exit(0); } +const loginMarker = process.env.T3_CLAUDE_LOGIN_MARKER; +const signedOut = loginMarker !== undefined && !NodeFS.existsSync(loginMarker); +if (process.env.T3_CLAUDE_PROBE_LOG) + NodeFS.appendFileSync(process.env.T3_CLAUDE_PROBE_LOG, "probe\n"); const lines = NodeReadline.createInterface({ input: process.stdin }); lines.on("line", (line) => { const message = JSON.parse(line); if (message.type !== "control_request") return; + if (message.request?.subtype === "get_usage" && signedOut) { + process.stdout.write( + JSON.stringify({ + type: "control_response", + response: { + subtype: "success", + request_id: message.request_id, + response: { session: {}, rate_limits_available: false, rate_limits: null }, + }, + }) + "\n", + ); + return; + } if (message.request?.subtype === "get_usage") { const marker = process.env.T3_CLAUDE_RESET_MARKER; - if (process.env.T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM && marker && NodeFS.existsSync(marker)) { + const usageFailsUntil = process.env.T3_CLAUDE_USAGE_FAILS_UNTIL; + if ( + (usageFailsUntil && !NodeFS.existsSync(usageFailsUntil)) || + (process.env.T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM && marker && NodeFS.existsSync(marker)) + ) { process.stdout.write( JSON.stringify({ type: "control_response", @@ -60,7 +81,9 @@ lines.on("line", (line) => { models: [], output_style: "default", available_output_styles: ["default"], - account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" }, + account: signedOut + ? { tokenSource: "none", apiProvider: "firstParty" } + : { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" }, }, }, }) + "\n", diff --git a/apps/server/src/provider/unavailableProviderSnapshot.ts b/apps/server/src/provider/unavailableProviderSnapshot.ts index 368c7e86d460..8d34deab87b1 100644 --- a/apps/server/src/provider/unavailableProviderSnapshot.ts +++ b/apps/server/src/provider/unavailableProviderSnapshot.ts @@ -19,38 +19,37 @@ export interface UnavailableProviderSnapshotInput { const nowIso = Effect.map(DateTime.now, DateTime.formatIso); -export function buildUnavailableProviderSnapshot( - input: UnavailableProviderSnapshotInput, -): Effect.Effect { - return Effect.gen(function* () { - const checkedAt = input.checkedAt ?? (yield* nowIso); - const displayName = input.displayName?.trim() || (input.driverKind as string); +export const buildUnavailableProviderSnapshot = Effect.fn( + "unavailableProviderSnapshot.buildUnavailableProviderSnapshot", +)(function* (input: UnavailableProviderSnapshotInput): Effect.fn.Return { + const checkedAt = input.checkedAt ?? (yield* nowIso); + const displayName = input.displayName?.trim() || (input.driverKind as string); - const base = buildServerProvider({ - presentation: { displayName }, - enabled: false, - checkedAt, - models: [], - skills: [], - probe: { - installed: false, - version: null, - status: "error", - auth: { status: "unknown" }, - message: input.reason, - }, - }); - - return { - ...base, - instanceId: input.instanceId, - ...(input.accentColor ? { accentColor: input.accentColor } : {}), - driver: - typeof input.driverKind === "string" - ? ProviderDriverKind.make(input.driverKind) - : input.driverKind, - availability: "unavailable", - unavailableReason: input.reason, - }; + // No driver ran, so its setup capabilities are still unknown. + const { setup: _setup, ...base } = buildServerProvider({ + presentation: { displayName }, + enabled: false, + checkedAt, + models: [], + skills: [], + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unknown" }, + message: input.reason, + }, }); -} + + return { + ...base, + instanceId: input.instanceId, + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + driver: + typeof input.driverKind === "string" + ? ProviderDriverKind.make(input.driverKind) + : input.driverKind, + availability: "unavailable", + unavailableReason: input.reason, + }; +}); diff --git a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts deleted file mode 100644 index 4b8f3c38ac13..000000000000 --- a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; -import { assert, it } from "@effect/vitest"; - -import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; -import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; -import * as GitHubRequestPolicy from "@t3tools/source-control-github/server/GitHubRequestPolicy"; -import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; -import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; -import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider"; -import { bitbucketProviderFailure } from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestProvider"; -import { gitHubProviderFailure } from "@t3tools/source-control-github/server/GitHubPullRequestProvider"; -import { gitLabProviderFailure } from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider"; - -const cause = new Error("redacted provider failure"); - -it("classifies rate limits from every pull-request provider", () => { - assert.deepStrictEqual( - gitHubProviderFailure( - new GitHubRequestPolicy.GitHubApiRateLimitError({ host: "github.com", operation: "execute" }), - ), - { reason: "rate-limited" }, - ); - assert.deepStrictEqual( - gitLabProviderFailure( - new GitLabCli.GitLabCliRateLimitError({ - operation: "execute", - command: "glab", - cwd: "/repo", - cause, - }), - ), - { reason: "rate-limited" }, - ); - assert.deepStrictEqual( - azureDevOpsProviderFailure( - new AzureDevOpsCli.AzureDevOpsCliRateLimitError({ - operation: "execute", - command: "az", - cwd: "/repo", - argumentCount: 1, - cause, - }), - ), - { reason: "rate-limited" }, - ); - assert.deepStrictEqual( - bitbucketProviderFailure( - new BitbucketApi.BitbucketResponseError({ - operation: "request", - status: 429, - responseBodyLength: 0, - retryAt: 120_000, - }), - ), - { reason: "rate-limited", retryAt: 120_000 }, - ); -}); - -it("keeps GitHub's exact retry time", () => { - assert.deepStrictEqual( - gitHubProviderFailure( - new SourceControlRateLimit.SourceControlRateLimitPausedError({ - provider: SourceControlProviderKind.make("github"), - host: "github.com", - retryAt: 1_786_802_400_000, - }), - ), - { reason: "rate-limited", retryAt: 1_786_802_400_000 }, - ); -}); diff --git a/apps/server/src/pullRequest/PullRequestReadCache.test.ts b/apps/server/src/pullRequest/PullRequestReadCache.test.ts index a4c0c8b17879..2d2fcfc1f05a 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.test.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.test.ts @@ -1,123 +1,17 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { PullRequestOperationError } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; -import * as Queue from "effect/Queue"; -import * as TestClock from "effect/testing/TestClock"; import * as KeyValueStore from "effect/persistence/KeyValueStore"; -import * as ServerConfig from "../config.ts"; import * as PullRequestReadCache from "./PullRequestReadCache.ts"; const cacheLayer = (directory: string) => PullRequestReadCache.make.pipe(Effect.provide(KeyValueStore.layerFileSystem(directory))); -const ageFiles = (directory: string, names: ReadonlyArray, age: Duration.Duration) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const mtime = DateTime.toDateUtc(DateTime.subtractDuration(yield* DateTime.now, age)); - for (const name of names) yield* fs.utimes(path.join(directory, name), mtime, mtime); - }); - it.layer(NodeServices.layer)("PR filesystem cache", (it) => { - it.effect("prunes entry files written before the max age and keeps fresh ones", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); - yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe("2026-10-01T00:00:00Z"))); - const cache = yield* cacheLayer(directory); - yield* cache.get("old", Effect.succeed("old"), ["pr"]); - yield* cache.invalidate("pr"); - const [stale, revisions] = (yield* fs.readDirectory(directory)).toSorted(); - assert.strictEqual(revisions, "revisions"); - yield* cache.get("fresh", Effect.succeed("fresh")); - const fresh = (yield* fs.readDirectory(directory)).find( - (name) => name !== stale && name !== revisions, - ); - const unrelated = "unrelated.json"; - yield* fs.writeFileString(`${directory}/${unrelated}`, "{}"); - const prefixed = "pr-v2-backup.json"; - yield* fs.writeFileString(`${directory}/${prefixed}`, "{}"); - const justExpired = Duration.sum( - PullRequestReadCache.ENTRY_FILE_MAX_AGE, - Duration.seconds(1), - ); - yield* ageFiles(directory, [stale!, revisions!, unrelated, prefixed], justExpired); - yield* ageFiles(directory, [fresh!], PullRequestReadCache.ENTRY_FILE_MAX_AGE); - - yield* PullRequestReadCache.pruneExpiredEntryFiles(directory); - - assert.deepStrictEqual( - (yield* fs.readDirectory(directory)).toSorted(), - [fresh!, revisions!, unrelated, prefixed].toSorted(), - ); - }), - ); - - it.effect("sweeps the cache directory every hour once the layer is built", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const { providerStatusCacheDir } = yield* ServerConfig.ServerConfig; - const directory = `${providerStatusCacheDir}/pull-requests`; - yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe("2026-10-01T00:00:00Z"))); - yield* (yield* cacheLayer(directory)).get("summary", Effect.succeed("cached")); - const [entry] = yield* fs.readDirectory(directory); - yield* ageFiles( - directory, - [entry!], - Duration.subtract(PullRequestReadCache.ENTRY_FILE_MAX_AGE, Duration.minutes(90)), - ); - const operations = yield* Queue.unbounded(); - const observedFs = FileSystem.FileSystem.of({ - ...fs, - stat: (path) => fs.stat(path).pipe(Effect.tap(() => Queue.offer(operations, "stat"))), - remove: (path, options) => - fs.remove(path, options).pipe(Effect.tap(() => Queue.offer(operations, "remove"))), - }); - yield* Layer.build( - PullRequestReadCache.layer.pipe( - Layer.provide(Layer.succeed(FileSystem.FileSystem, observedFs)), - ), - ); - assert.strictEqual(yield* Queue.take(operations), "stat"); - yield* TestClock.adjust("1 hour"); - assert.strictEqual(yield* Queue.take(operations), "stat"); - assert.deepStrictEqual(yield* fs.readDirectory(directory), [entry]); - yield* TestClock.adjust("1 hour"); - assert.deepStrictEqual(yield* Queue.takeN(operations, 2), ["stat", "remove"]); - assert.deepStrictEqual(yield* fs.readDirectory(directory), []); - }).pipe( - Effect.scoped, - Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-pr-cache-layer-" })), - ), - ); - - it.effect("reuses files after restart and respects the original expiry", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); - let reads = 0; - const lookup = Effect.sync(() => String(++reads)); - const first = yield* cacheLayer(directory); - const key = "long/repository/key".repeat(100); - assert.strictEqual(yield* first.get(key, lookup), "1"); - yield* TestClock.adjust("59 seconds"); - const restarted = yield* cacheLayer(directory); - assert.strictEqual(yield* restarted.get(key, lookup), "1"); - yield* TestClock.adjust("1 second"); - assert.strictEqual(yield* restarted.get(key, lookup), "2"); - assert.strictEqual(reads, 2); - assert.strictEqual((yield* fs.readDirectory(directory)).length, 1); - }), - ); - it.effect("clears in-flight reads before a new service can reuse them", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -212,29 +106,6 @@ it.layer(NodeServices.layer)("PR filesystem cache", (it) => { }).pipe(Effect.provide(KeyValueStore.layerMemory)), ); - it.effect("compacts expired scope records without discarding fresh PR data", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); - const cache = yield* cacheLayer(directory); - yield* cache.get("summary", Effect.succeed("old"), ["pr"]); - yield* cache.invalidate("pr"); - for (let index = 0; index < 100; index++) yield* cache.invalidate(`pr-${index}`); - assert.strictEqual((yield* fs.readDirectory(directory)).length, 2); - const before = (yield* fs.stat(`${directory}/revisions`)).size; - yield* TestClock.adjust("59 seconds"); - assert.strictEqual(yield* cache.get("summary", Effect.succeed("fresh"), ["pr"]), "fresh"); - yield* TestClock.adjust("1 second"); - yield* cache.invalidate("other-pr"); - assert.isTrue((yield* fs.stat(`${directory}/revisions`)).size < before); - const restarted = yield* cacheLayer(directory); - assert.strictEqual( - yield* restarted.get("summary", Effect.die("cache miss"), ["pr"]), - "fresh", - ); - }), - ); - it.effect("does not persist failed GitHub reads", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts index 16860c78ee9a..8d9691fd79e3 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -32,7 +32,7 @@ const ENTRY_FILE_NAME = new RegExp(`^${STORE_ID}[0-9a-f]{64}$`); * A file untouched for a day is long expired; the day only leaves slack for * clock changes. */ -export const ENTRY_FILE_MAX_AGE = Duration.days(1); +const ENTRY_FILE_MAX_AGE = Duration.days(1); type ReadError = PullRequestOperationError | PullRequestUnavailableError; const revisionCodec = Schema.fromJsonString( Schema.Record( @@ -153,8 +153,8 @@ export const make = Effect.gen(function* () { lock.withPermits(1), ); }), - invalidate: (scope) => - Effect.gen(function* () { + invalidate: Effect.fn("PullRequestReadCache.invalidate")( + function* (scope) { const now = clock.currentTimeMillisUnsafe(); const current = yield* Cache.get(revisions, undefined); const next = Object.fromEntries( @@ -164,14 +164,14 @@ export const make = Effect.gen(function* () { const encoded = yield* Schema.encodeEffect(revisionCodec)(next); yield* backing.set("revisions", encoded); yield* Cache.set(revisions, undefined, next); - }).pipe( - Effect.catch(() => { - enabled = false; - return Effect.logWarning("PR cache disabled after clearing failed"); - }), - Effect.uninterruptible, - lock.withPermits(CONCURRENT_READS), - ), + }, + Effect.catch(() => { + enabled = false; + return Effect.logWarning("PR cache disabled after clearing failed"); + }), + Effect.uninterruptible, + lock.withPermits(CONCURRENT_READS), + ), }); }); @@ -179,33 +179,33 @@ export const make = Effect.gen(function* () { * The persisted cache drops an expired entry only when it is read again, so * files for PRs nobody reopens would otherwise stay forever. */ -export const pruneExpiredEntryFiles = Effect.fn("PullRequestReadCache.pruneExpiredEntryFiles")( - function* (directory: string) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(ENTRY_FILE_MAX_AGE); - const entries = (yield* fileSystem.readDirectory(directory)).filter((name) => - ENTRY_FILE_NAME.test(name), +const pruneExpiredEntryFiles = Effect.fn("PullRequestReadCache.pruneExpiredEntryFiles")(function* ( + directory: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(ENTRY_FILE_MAX_AGE); + const entries = (yield* fileSystem.readDirectory(directory)).filter((name) => + ENTRY_FILE_NAME.test(name), + ); + const [, failures] = yield* Effect.partition(entries, (name) => { + const entryPath = path.join(directory, name); + return fileSystem.stat(entryPath).pipe( + Effect.flatMap((info) => + Option.exists(info.mtime, (mtime) => mtime.getTime() < cutoff) + ? fileSystem.remove(entryPath) + : Effect.void, + ), + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), ); - const [, failures] = yield* Effect.partition(entries, (name) => { - const entryPath = path.join(directory, name); - return fileSystem.stat(entryPath).pipe( - Effect.flatMap((info) => - Option.exists(info.mtime, (mtime) => mtime.getTime() < cutoff) - ? fileSystem.remove(entryPath) - : Effect.void, - ), - Effect.catchReason("PlatformError", "NotFound", () => Effect.void), - ); + }); + if (failures.length > 0) { + yield* Effect.logWarning("Failed to prune some PR cache files", { + failed: failures.length, + cause: failures[0], }); - if (failures.length > 0) { - yield* Effect.logWarning("Failed to prune some PR cache files", { - failed: failures.length, - cause: failures[0], - }); - } - }, -); + } +}); export const layer = Layer.unwrap( Effect.gen(function* () { @@ -214,17 +214,18 @@ export const layer = Layer.unwrap( const directory = path.join(config.providerStatusCacheDir, "pull-requests"); return Layer.effect(PullRequestReadCache, make).pipe( Layer.provide( - KeyValueStore.layerFileSystem(directory).pipe( - Layer.tap(() => - forkParked( - pruneExpiredEntryFiles(directory).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to prune PR cache files", { cause }), - ), - Effect.repeat(Schedule.spaced(Duration.hours(1))), + // Memoize cleanup acquisition with the store; Layer.tap runs per consumer. + Layer.effectDiscard( + forkParked( + pruneExpiredEntryFiles(directory).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to prune PR cache files", { cause }), ), + Effect.repeat(Schedule.spaced(Duration.hours(1))), ), ), + ).pipe( + Layer.provideMerge(KeyValueStore.layerFileSystem(directory)), Layer.catch(() => Layer.effectDiscard( Effect.logWarning("PR cache directory unavailable; using memory cache"), diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts index 403424cdc597..76f703123c27 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts @@ -3,10 +3,12 @@ import { assert, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; -import * as Schedule from "effect/Schedule"; +import * as Queue from "effect/Queue"; +import * as Tracer from "effect/Tracer"; import { ProjectId, GitCommandError, @@ -15,7 +17,6 @@ import { type PullRequestStack, type PullRequestRef, } from "@t3tools/contracts"; -import type { ReviewGuideGenerationInput } from "@t3tools/provider-core/server/textGeneration"; import * as ServerConfig from "../config.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; @@ -88,7 +89,7 @@ const makeRepositories = Effect.gen(function* () { yield* git(remote, ["checkout", "--quiet", "main"]); yield* commit("unrelated.txt", "main moved on"); yield* git(root, ["clone", "--quiet", remote, workspace]); - return { root, workspace, headSha, lowerSha, upperSha }; + return { root, remote, workspace, headSha, lowerSha, upperSha }; }); const detail = (workspaceRoot: string, headSha: string): PullRequestDetail => @@ -147,6 +148,7 @@ const stack = (repositories: { const makeLayer = (input: { readonly root: string; + readonly remote: string; readonly workspace: string; readonly headSha: string; readonly lowerSha: string; @@ -191,7 +193,10 @@ const makeLayer = (input: { Effect.succeedSome({ id: projectId, workspaceRoot: input.workspace, - repositoryIdentity: input.identity ?? ACME_APP, + repositoryIdentity: { + ...(input.identity ?? ACME_APP), + locator: { ...ACME_APP.locator, remoteUrl: input.remote }, + }, }), } as unknown as ProjectService.ProjectService["Service"]), ), @@ -219,18 +224,33 @@ const layerBase = Layer.mergeAll( type Repositories = Effect.Success; -const awaitRunFailure = ( - service: PullRequestReviewGuideService.PullRequestReviewGuideService["Service"], - input: PullRequestRef, -) => - service.get(input).pipe( - Effect.flatMap((result) => - result.run?._tag === "Failed" - ? Effect.succeed(result.run.detail) - : Effect.fail("still running" as const), - ), - Effect.retry(Schedule.spaced("20 millis")), - ); +// A run forks into the service's scope, so a test learns it settled when the run's `write` span +// ends; the run records its outcome synchronously right after. +const watchRuns = Effect.gen(function* () { + const ended = yield* Queue.unbounded(); + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + if (options.name !== "PullRequestReviewGuideService.write") return span; + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + Queue.offerUnsafe(ended, undefined); + }; + return span; + }, + }); + return { + generate: ( + service: PullRequestReviewGuideService.PullRequestReviewGuideService["Service"], + input: PullRequestRef, + ) => service.generate(input).pipe(Effect.withTracer(tracer)), + settled: ( + service: PullRequestReviewGuideService.PullRequestReviewGuideService["Service"], + input: PullRequestRef, + ) => Queue.take(ended).pipe(Effect.andThen(service.get(input))), + }; +}); const startSingleBranchRun = (input: { readonly repositoryName: string; @@ -326,7 +346,8 @@ const startSingleBranchRun = (input: { PullRequestReviewGuideService.PullRequestReviewGuideService, ); const guideReference = { ...reference, repository: `acme/${input.repositoryName}` }; - yield* service.generate(guideReference); + const runs = yield* watchRuns; + yield* runs.generate(service, guideReference); return { repositories, reportedHead, @@ -335,146 +356,11 @@ const startSingleBranchRun = (input: { stored, fetchedRefs, observed, + settled: runs.settled(service, guideReference), }; }); -// Runs drive real git, which the test clock does not advance, so waits use real time. it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideService", (it) => { - it.effect.each([ - { - name: "a short head already fetched", - shortHash: true, - prepare: "fetch-feature", - pushDuringFetch: false, - refuseCommitFetch: true, - expectedFetches: [], - }, - { - name: "a short head on its branch", - shortHash: true, - prepare: "none", - pushDuringFetch: false, - refuseCommitFetch: true, - expectedFetches: ["refs/heads/feature"], - }, - { - name: "a short head whose branch moves during the fetch", - shortHash: true, - prepare: "none", - pushDuringFetch: true, - refuseCommitFetch: true, - expectedFetches: ["refs/heads/feature"], - }, - { - name: "a full head served by commit", - shortHash: false, - prepare: "none", - pushDuringFetch: false, - refuseCommitFetch: false, - expectedFetches: [""], - }, - { - name: "a full head on its branch", - shortHash: false, - prepare: "none", - pushDuringFetch: false, - refuseCommitFetch: true, - expectedFetches: ["", "refs/heads/feature"], - }, - { - name: "a full head only on its pull ref", - shortHash: false, - prepare: "refs/pull/7/head", - pushDuringFetch: false, - refuseCommitFetch: true, - expectedFetches: ["", "refs/heads/feature", "refs/pull/7/head"], - }, - { - name: "a full head only on its merge request ref", - shortHash: false, - prepare: "refs/merge-requests/7/head", - pushDuringFetch: false, - refuseCommitFetch: true, - expectedFetches: [ - "", - "refs/heads/feature", - "refs/pull/7/head", - "refs/merge-requests/7/head", - ], - }, - ])( - "writes a guide for the reported commit: $name", - ({ shortHash, prepare, pushDuringFetch, refuseCommitFetch, expectedFetches }) => - Effect.scoped( - Effect.gen(function* () { - const run = yield* startSingleBranchRun({ - repositoryName: "fetched-head", - reportedHead: (repositories) => - shortHash ? repositories.headSha.slice(0, 12) : repositories.headSha, - refuseCommitFetch, - prepare: (repositories, workspace) => { - const remote = `${repositories.root}/remote`; - if (prepare === "fetch-feature") { - return git(workspace, ["fetch", "--quiet", "origin", "feature"]).pipe( - Effect.asVoid, - ); - } - if (prepare === "none") return Effect.void; - return git(remote, ["update-ref", prepare, repositories.headSha]).pipe( - Effect.andThen( - git(remote, ["update-ref", "refs/heads/feature", repositories.lowerSha]), - ), - Effect.asVoid, - ); - }, - onBranchFetch: pushDuringFetch - ? (repositories) => { - const remote = `${repositories.root}/remote`; - return git(remote, ["checkout", "--quiet", "feature"]).pipe( - Effect.andThen( - git(remote, [ - "commit", - "--quiet", - "--allow-empty", - "-m", - "push during fetch", - ]), - ), - Effect.asVoid, - ); - } - : undefined, - }); - yield* Effect.raceFirst( - Deferred.await(run.stored), - awaitRunFailure(run.service, run.reference).pipe( - Effect.flatMap((detail) => Effect.die(`run failed: ${detail}`)), - ), - ); - const { headSha } = run.repositories; - const result = yield* run.service.get(run.reference); - assert.strictEqual(result.guide?.headSha, run.reportedHead); - assert.deepStrictEqual(result.guide?.stops[0]?.files, [{ path: "feature.txt" }]); - assert.deepStrictEqual(run.observed, [ - { cwd: run.observed[0]?.cwd, head: headSha, changed: "feature.txt" }, - ]); - const headRefs = new Set([ - headSha, - run.reportedHead, - "refs/heads/feature", - "refs/pull/7/head", - "refs/merge-requests/7/head", - ]); - assert.deepStrictEqual( - run.fetchedRefs.filter((ref) => headRefs.has(ref)), - expectedFetches.map((ref) => (ref === "" ? headSha : ref)), - ); - const fs = yield* FileSystem.FileSystem; - assert.isFalse(yield* fs.exists(run.observed[0]?.cwd ?? "")); - }), - ), - ); - it.effect.each([ { name: "a branch named like the short head", @@ -524,10 +410,10 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic Effect.asVoid, ), }); - const detail = yield* Effect.raceFirst( - awaitRunFailure(run.service, run.reference), - Deferred.await(run.stored).pipe(Effect.andThen(Effect.die("a guide was written"))), - ); + const result = yield* run.settled; + assert.isFalse(yield* Deferred.isDone(run.stored), "a guide was written"); + assert.strictEqual(result.run?._tag, "Failed"); + const detail = result.run?._tag === "Failed" ? result.run.detail : ""; for (const part of expected) assert.include(detail, part); assert.deepStrictEqual(run.observed, []); assert.isFalse(run.fetchedRefs.some((ref) => ref.startsWith("-"))); @@ -537,237 +423,102 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic ), ); - it.effect("briefs the change against its own base in a checkout it then removes", () => + it.effect("measures only the PR change using the renamed verified remote's fetch URL", () => Effect.scoped( Effect.gen(function* () { const repositories = yield* makeRepositories; - const seen = yield* Deferred.make<{ - readonly input: ReviewGuideGenerationInput; - readonly changed: string; - readonly head: string; - readonly upper: string; - }>(); - const finish = yield* Deferred.make(); + const fork = `${repositories.root}/fork`; + yield* git(repositories.root, ["clone", "--quiet", repositories.remote, fork]); + yield* git(fork, ["branch", "stack-base", "main~1"]); + // The cached identity still names origin, which now belongs to the stale fork. + yield* git(repositories.workspace, ["remote", "rename", "origin", "upstream"]); + yield* git(repositories.workspace, ["remote", "add", "origin", fork]); + yield* git(repositories.workspace, [ + "config", + `url.${repositories.remote}.insteadOf`, + "review-upstream:", + ]); + yield* git(repositories.workspace, ["remote", "set-url", "upstream", "review-upstream:"]); + const changed = yield* Deferred.make(); const driver = yield* GitVcsDriver.GitVcsDriver; - const readCheckout = (cwd: string, args: ReadonlyArray) => - git(cwd, args).pipe( - Effect.provideService(GitVcsDriver.GitVcsDriver, driver), - Effect.orDie, - ); const stored = yield* Deferred.make(); - - // Built in this test's scope: runs are forked into the service's scope and must outlive - // the call that obtains the service. const context = yield* Layer.build( makeLayer({ ...repositories, stored, generate: (input) => - Effect.gen(function* () { - const changed = yield* readCheckout(input.cwd, [ - "diff", - "--name-only", - `${input.baseCommit}...HEAD`, - ]); - const head = yield* readCheckout(input.cwd, ["rev-parse", "HEAD"]); - const upper = yield* readCheckout(input.cwd, [ - "show", - `${repositories.upperSha}:upper.txt`, - ]); - yield* Deferred.succeed(seen, { input, changed, head, upper }); - yield* Deferred.await(finish); - return { - stops: [ - { - title: "The feature", - summary: "The change.", - attention: null, - excerpts: [ - { path: "feature.txt", side: "new", start: 4, end: 40, caption: "" }, - ], - files: [], - notes: [ - { path: "feature.txt", line: 5, title: "Kept", body: "Inside." }, - { path: "feature.txt", line: 30, title: "Dropped", body: "Outside." }, - ], - questions: [], - }, - ], - }; - }), + git(input.cwd, ["diff", "--name-only", `${input.baseCommit}...HEAD`]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + Effect.orDie, + Effect.flatMap((names) => Deferred.succeed(changed, names)), + Effect.as({ stops: [] }), + ), }), ); const service = Context.get( context, PullRequestReviewGuideService.PullRequestReviewGuideService, ); - - const started = yield* service.generate(reference); - assert.strictEqual(started.run?._tag, "Running"); - assert.strictEqual(started.guide, null); - assert.isTrue(started.started); - - const failedRun = service.get(reference).pipe( - Effect.flatMap((result) => - result.run?._tag === "Failed" - ? Effect.die(`run failed: ${result.run.detail}`) - : Effect.fail("still running" as const), - ), - Effect.retry(Schedule.spaced("20 millis")), - ); - const observed = yield* Effect.raceFirst(Deferred.await(seen), failedRun); - assert.strictEqual(observed.head, repositories.headSha); - assert.strictEqual(observed.changed, "feature.txt"); - assert.strictEqual(observed.input.baseBranch, "stack-base"); - assert.deepStrictEqual(observed.input.stack, [ - { number: 6, title: "Lower", state: "open", headSha: repositories.lowerSha }, - { number: 7, title: "Add the feature", state: "open", headSha: repositories.headSha }, - { number: 8, title: "Upper", state: "open", headSha: repositories.upperSha }, - { number: 9, title: "Unpushed", state: "open", headSha: null }, - ]); - assert.strictEqual(observed.upper, "upper layer"); - - const joined = yield* service.generate(reference); - assert.strictEqual(joined.run, started.run); - assert.isFalse(joined.started); - - yield* Deferred.succeed(finish, undefined); + yield* service.generate(reference); + assert.strictEqual(yield* Deferred.await(changed), "feature.txt"); yield* Deferred.await(stored); - const finished = yield* service.get(reference); - assert.strictEqual(finished.guide?.headSha, repositories.headSha); - const guide = finished.guide; - assert.isNotNull(guide); - const [story] = guide.stops; - assert.deepStrictEqual(story?.excerpts, [ - { path: "feature.txt", side: "new", start: 4, end: 10, caption: "" }, - ]); - assert.deepStrictEqual( - story?.notes.map((note) => note.title), - ["Kept"], - ); - assert.strictEqual(guide.stops.length, 1); - - const fs = yield* FileSystem.FileSystem; - assert.isFalse(yield* fs.exists(observed.input.cwd)); - const worktrees = yield* git(repositories.workspace, ["worktree", "list", "--porcelain"]); - assert.notInclude(worktrees, observed.input.cwd); + const result = yield* service.get(reference); + assert.strictEqual(result.guide?.headSha, repositories.headSha); + assert.deepStrictEqual(result.guide?.stops[0]?.files, [{ path: "feature.txt" }]); }), ), ); - it.effect("refuses to write a guide while the server has review guides off", () => + it.effect("refuses generation when the verified remote disappeared despite a usable origin", () => Effect.scoped( Effect.gen(function* () { const repositories = yield* makeRepositories; + const fork = `${repositories.root}/fork`; + yield* git(repositories.root, ["clone", "--quiet", repositories.remote, fork]); + yield* git(fork, ["branch", "stack-base", repositories.lowerSha]); + yield* git(repositories.workspace, ["remote", "set-url", "origin", fork]); + const started = yield* Deferred.make>(); const stored = yield* Deferred.make(); + const generated = yield* Ref.make(false); + const driver = yield* GitVcsDriver.GitVcsDriver; const context = yield* Layer.build( makeLayer({ ...repositories, stored, - enabled: false, - generate: () => Effect.die("a guide must not be written while the beta is off"), - }), + generate: () => Ref.set(generated, true).pipe(Effect.as({ stops: [] })), + }).pipe( + Layer.provide( + Layer.succeed(GitVcsDriver.GitVcsDriver, { + ...driver, + execute: (call) => + Effect.withFiber((fiber) => + Deferred.succeed(started, fiber).pipe(Effect.andThen(driver.execute(call))), + ), + }), + ), + ), ); const service = Context.get( context, PullRequestReviewGuideService.PullRequestReviewGuideService, ); - const result = yield* Effect.flip(service.generate(reference)); - assert.include(result.message, "Review guides are off"); + const before = yield* service.get(reference); + yield* service.generate(reference); + // Join the background run so its failure is visible without polling service state. + yield* Fiber.await(yield* Deferred.await(started)); + const result = yield* service.get(reference); + assert.strictEqual(result.run?._tag, "Failed"); + assert.include( + result.run?._tag === "Failed" ? result.run.detail : "", + "no longer has a remote for the pull request's repository", + ); + assert.deepStrictEqual(result.guide, before.guide); + assert.isFalse(yield* Deferred.isDone(stored)); + assert.isFalse(yield* Ref.get(generated)); }), ), ); - it.effect( - "keeps a newer run's guide when an older run for the same pull request finishes late", - () => - Effect.scoped( - Effect.gen(function* () { - const repositories = yield* makeRepositories; - const stored = yield* Deferred.make(); - const currentHead = yield* Ref.make(repositories.headSha); - const releaseOld = yield* Deferred.make(); - const oldStarted = yield* Deferred.make(); - const answer = (title: string) => ({ - stops: [ - { - title, - summary: "", - attention: null, - excerpts: [], - files: [], - notes: [], - questions: [], - }, - ], - }); - const context = yield* Layer.build( - makeLayer({ - ...repositories, - stored, - currentHead, - generate: (input) => - input.cwd.length > 0 && input.baseCommit.length > 0 - ? Ref.get(currentHead).pipe( - Effect.flatMap((head) => - head === repositories.headSha - ? Deferred.succeed(oldStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseOld)), - Effect.as(answer("old head")), - ) - : Effect.succeed(answer("new head")), - ), - ) - : Effect.die("unreachable"), - }), - ); - const service = Context.get( - context, - PullRequestReviewGuideService.PullRequestReviewGuideService, - ); - - yield* service.generate(reference); - const firstFailed = service.get(reference).pipe( - Effect.flatMap((result) => - result.run?._tag === "Failed" - ? Effect.die(`first run failed: ${result.run.detail}`) - : Effect.fail("still running" as const), - ), - Effect.retry(Schedule.spaced("20 millis")), - ); - yield* Effect.raceFirst(Deferred.await(oldStarted), firstFailed); - yield* Ref.set(currentHead, repositories.upperSha); - const second = yield* service.generate(reference); - assert.strictEqual(second.run?._tag, "Running"); - assert.strictEqual(second.run?.headSha, repositories.upperSha); - const failedRun = service.get(reference).pipe( - Effect.flatMap((result) => - result.run?._tag === "Failed" - ? Effect.die(`run failed: ${result.run.detail}`) - : Effect.fail("still running" as const), - ), - Effect.retry(Schedule.spaced("20 millis")), - ); - yield* Effect.raceFirst(Deferred.await(stored), failedRun); - - yield* Deferred.succeed(releaseOld, undefined); - const settled = yield* service.get(reference).pipe( - Effect.filterOrFail( - (result) => result.run === null, - () => "still running" as const, - ), - Effect.retry({ schedule: Schedule.spaced("20 millis"), times: 250 }), - ); - assert.strictEqual(settled.guide?.headSha, repositories.upperSha); - yield* Effect.sleep("200 millis"); - const after = yield* service.get(reference); - assert.isNull(after.run); - assert.strictEqual(after.guide?.headSha, repositories.upperSha); - assert.strictEqual(after.guide?.stops[0]?.title, "new head"); - }), - ), - ); - it.effect("refuses a pull request from a repository the project is not a checkout of", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts index 8086541067fb..edd5dd2c7286 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts @@ -108,13 +108,19 @@ const make = Effect.gen(function* () { }, identity, ); - if (own === null || own.host !== key.host || own.repository !== key.repository) { + if ( + identity == null || + own === null || + own.host !== key.host || + own.repository !== key.repository + ) { return yield* new PullRequestOperationError({ operation: "generateReviewGuide", detail: "Guides are written from this project's checkout, and this pull request is in another repository. Write it from a project that is a checkout of that repository.", }); } + return identity.locator; }, ); @@ -148,12 +154,40 @@ const make = Effect.gen(function* () { const checkoutHead = Effect.fn("PullRequestReviewGuideService.checkoutHead")(function* ( workspaceRoot: string, + locator: RepositoryIdentity["locator"], detail: PullRequestDetail, headSha: string, stack: PullRequestStack | null, runId: string, ) { - const remote = yield* git.resolvePrimaryRemoteName(workspaceRoot); + // Identity names can be stale; match effective fetch URLs, including insteadOf rewrites. + const names = yield* git + .execute({ + operation: "PullRequestReviewGuideService.listRemotes", + cwd: workspaceRoot, + args: ["remote"], + }) + .pipe(Effect.map((result) => result.stdout.split("\n").filter((name) => name.length > 0))); + const remote = yield* Effect.findFirst( + [locator.remoteName, ...names.filter((name) => name !== locator.remoteName)], + (name) => + git + .execute({ + operation: "PullRequestReviewGuideService.remoteUrl", + cwd: workspaceRoot, + args: ["remote", "get-url", "--", name], + }) + .pipe( + Effect.map((result) => result.stdout.trim() === locator.remoteUrl), + Effect.orElseSucceed(() => false), + ), + ).pipe(Effect.map(Option.getOrUndefined)); + if (remote === undefined) { + return yield* new PullRequestOperationError({ + operation: "generateReviewGuide", + detail: "This project's checkout no longer has a remote for the pull request's repository.", + }); + } const fetch = (args: ReadonlyArray) => git.execute({ operation: "PullRequestReviewGuideService.fetch", @@ -327,6 +361,7 @@ const make = Effect.gen(function* () { input: PullRequestRef, key: ThreadPullRequestKey, workspaceRoot: string, + locator: RepositoryIdentity["locator"], detail: PullRequestDetail, headSha: string, runId: string, @@ -349,6 +384,7 @@ const make = Effect.gen(function* () { ); const { generated, change } = yield* checkoutHead( workspaceRoot, + locator, detail, headSha, stack, @@ -412,7 +448,7 @@ const make = Effect.gen(function* () { }); } const { key, workspaceRoot, identity } = yield* keyFor(input); - yield* requireOwnRepository(key, identity); + const locator = yield* requireOwnRepository(key, identity); const runKey = keyString(key); yield* pullRequests.invalidate({ reference: input }); const [detail, stored] = yield* Effect.all([pullRequests.detail(input), readStored(key)], { @@ -465,7 +501,7 @@ const make = Effect.gen(function* () { return updated; }); const runId = `${runCounter++}`; - yield* write(input, key, workspaceRoot, detail, currentHeadSha, runId, owns).pipe( + yield* write(input, key, workspaceRoot, locator, detail, currentHeadSha, runId, owns).pipe( Effect.andThen(finish(null)), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index d391e28614a7..23303df26ee7 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,12 +1,9 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { SourceControlProviderKind, VcsProcessExitError } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeV8 from "node:v8"; -import * as NodeVM from "node:vm"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as KeyValueStore from "effect/persistence/KeyValueStore"; -import { assert, it } from "@effect/vitest"; +import { assert, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; -import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -34,8 +31,14 @@ import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.t import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; +import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; +import * as GitLabPullRequestProvider from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; +import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; +import * as BitbucketPullRequestProvider from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestProvider"; import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; @@ -50,10 +53,6 @@ import { import * as PullRequestProviderRegistry from "./PullRequestProviderRegistry.ts"; import * as PullRequestService from "./PullRequestService.ts"; import * as PullRequestReadCache from "./PullRequestReadCache.ts"; -import { - FILE_REVISIONS_CACHE_CAPACITY, - MAX_FILE_REVISION_PATHS, -} from "./pullRequestViewedFiles.ts"; function project(input: { readonly id: string; @@ -131,237 +130,6 @@ function hostedChangeRequest(body: string, additions = 1) { }; } -it.effect("caches narrow previews and invalidates them after refresh or mutation", () => - Effect.gen(function* () { - let reads = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: () => Effect.die("preview must not read the viewer"), - getChangeRequestPreview: () => - Effect.sync(() => { - reads += 1; - return changeRequest(1, "2026-07-02T00:00:00Z"); - }), - }), - ], - }); - const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const previews = yield* Effect.all([service.preview(ref), service.preview(ref)], { - concurrency: 2, - }); - assert.deepStrictEqual(previews[0], { - ...ref, - title: "Change request 1", - url: "https://host/pull/1", - author: { login: "octocat", name: null, avatarUrl: null }, - state: "open", - isDraft: false, - createdAt: "2026-07-01T00:00:00Z", - }); - assert.strictEqual(reads, 1); - yield* service.invalidate({ reference: ref }); - yield* service.preview(ref); - assert.strictEqual(reads, 2); - yield* service.runAction({ ...ref, action: "close" }); - yield* service.preview(ref); - assert.strictEqual(reads, 3); - yield* service.refreshAfterTurn(ref.projectId); - yield* service.preview(ref); - assert.strictEqual(reads, 4); - const error = yield* Effect.flip(service.preview({ ...ref, repository: "another/repo" })); - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.strictEqual(reads, 4); - }), -); - -it.effect("keeps cached previews available and pauses uncached previews until quota resets", () => - Effect.gen(function* () { - let reads = 0; - const retryAt = Date.parse("2099-08-13T14:00:00Z"); - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestPreview: ({ number }) => - Effect.gen(function* () { - reads++; - if (reads === 2) - return yield* new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequestPreview", - reason: "rate-limited", - detail: "GitHub requests are paused until the rate limit resets.", - retryAt, - }); - return changeRequest(number, "2026-07-02T00:00:00Z"); - }), - }), - ], - }); - const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - yield* service.preview(ref); - const limited = yield* Effect.flip(service.preview({ ...ref, number: 2 })); - assert.instanceOf(limited, PullRequestOperationError); - if (limited._tag !== "PullRequestOperationError") return; - assert.include(limited.detail, "paused"); - assert.strictEqual((yield* service.preview(ref)).number, 1); - for (const number of [2, 3, 4]) { - const paused = yield* Effect.flip(service.preview({ ...ref, number })); - assert.instanceOf(paused, PullRequestOperationError); - if (paused._tag !== "PullRequestOperationError") return; - assert.include(paused.detail, "paused"); - } - assert.strictEqual(reads, 2); - yield* TestClock.setTime(retryAt); - assert.strictEqual((yield* service.preview({ ...ref, number: 2 })).number, 2); - assert.strictEqual(reads, 3); - }), -); - -it.effect("reuses only unexpired detail for previews", () => - Effect.gen(function* () { - let previewReads = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")), - getChangeRequestPreview: () => - Effect.sync(() => { - previewReads++; - return { - ...changeRequest(1, "2026-07-02T00:00:00Z"), - title: "Updated title", - state: "closed" as const, - }; - }), - }), - ], - }); - const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - yield* service.detail(ref); - assert.strictEqual((yield* service.preview(ref)).title, "Change request 1"); - assert.strictEqual(previewReads, 0); - yield* TestClock.adjust("61 seconds"); - const preview = yield* service.preview(ref); - assert.strictEqual(preview.title, "Updated title"); - assert.strictEqual(preview.state, "closed"); - assert.strictEqual(previewReads, 1); - }), -); - -it.effect("shares detail and activity for a minute, and for ten once merged", () => - Effect.gen(function* () { - const reads: Array = []; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: ({ number }) => - Effect.sync(() => { - reads.push(`detail #${number}`); - return { - ...hostedChangeRequest("Description"), - number, - state: - number === 1 - ? ("open" as const) - : number === 2 - ? ("merged" as const) - : ("closed" as const), - }; - }), - getChangeRequestActivity: ({ number }) => - Effect.sync(() => { - reads.push(`activity #${number}`); - return { - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }; - }), - }), - ], - }); - const reread = Effect.forEach([1, 2, 3], (number) => { - const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number }; - return Effect.andThen(service.detail(ref), service.activity(ref)); - }).pipe( - Effect.andThen(Effect.yieldNow), - Effect.andThen(Effect.sync(() => reads.splice(0).toSorted())), - ); - - const everything = [ - "activity #1", - "activity #2", - "activity #3", - "detail #1", - "detail #2", - "detail #3", - ]; - assert.deepStrictEqual(yield* reread, everything); - yield* TestClock.adjust("59 seconds"); - assert.deepStrictEqual(yield* reread, []); - yield* TestClock.adjust("2 seconds"); - assert.deepStrictEqual(yield* reread, ["activity #1", "activity #3", "detail #1", "detail #3"]); - yield* TestClock.adjust("9 minutes"); - assert.deepStrictEqual(yield* reread, everything); - }), -); - -it.effect("re-reads an open pull request after a turn, but not a merged one", () => - Effect.gen(function* () { - const reads: Array = []; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: ({ number }) => - Effect.sync(() => { - reads.push(`detail #${number}`); - return { - ...hostedChangeRequest("Description"), - number, - state: number === 1 ? ("open" as const) : ("merged" as const), - }; - }), - getChangeRequestActivity: ({ number }) => - Effect.sync(() => { - reads.push(`activity #${number}`); - return { - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }; - }), - }), - ], - }); - const merged = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; - const reread = Effect.forEach([1, 2], (number) => { - const ref = { ...merged, number }; - return Effect.andThen(service.detail(ref), service.activity(ref)); - }).pipe( - Effect.andThen(Effect.yieldNow), - Effect.andThen(Effect.sync(() => reads.splice(0).toSorted())), - ); - - assert.deepStrictEqual(yield* reread, ["activity #1", "activity #2", "detail #1", "detail #2"]); - for (let turn = 0; turn < 3; turn++) { - yield* service.refreshAfterTurn(merged.projectId); - assert.deepStrictEqual(yield* reread, ["activity #1", "detail #1"]); - } - yield* service.invalidate({ reference: merged }); - assert.deepStrictEqual(yield* reread, ["activity #2", "detail #2"]); - }), -); - it.effect("does not wait for an in-flight detail read to display a preview", () => Effect.gen(function* () { const detailStarted = yield* Deferred.make(); @@ -388,68 +156,6 @@ it.effect("does not wait for an in-flight detail read to display a preview", () }), ); -it.effect("keeps previews warm when another project finishes a turn", () => - Effect.gen(function* () { - const reads: string[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" }), - project({ id: "p2", title: "docs", workspaceRoot: "/d", repository: "acme/docs" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestPreview: (input) => - Effect.sync(() => { - reads.push(input.repository); - return changeRequest(1, "2026-07-02T00:00:00Z"); - }), - }), - ], - }); - const web = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const docs = { projectId: "p2" as ProjectId, repository: "acme/docs", number: 1 }; - yield* service.preview(web); - yield* service.preview(docs); - yield* service.preview({ ...web, host: "github.com" }); - assert.deepStrictEqual(reads, ["acme/web", "acme/docs"]); - yield* service.refreshAfterTurn(web.projectId); - yield* service.preview(web); - yield* service.preview(docs); - assert.deepStrictEqual(reads, ["acme/web", "acme/docs", "acme/web"]); - }), -); - -it.effect("uses full detail for hosts without a narrow preview", () => - Effect.gen(function* () { - let reads = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "web", - workspaceRoot: "/w", - repository: "acme/web", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - getChangeRequest: () => - Effect.sync(() => { - reads += 1; - return hostedChangeRequest("Description"); - }), - }), - ], - }); - const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const preview = yield* service.preview(ref); - assert.strictEqual(preview.title, "Change request 1"); - assert.strictEqual(reads, 1); - assert.ok(!("body" in preview)); - }), -); - function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") { return new PullRequestProviderError({ provider, @@ -714,73 +420,6 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos }), ); -it.effect("reports each host's actions with its summary, so rows can offer them unread", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "web", - workspaceRoot: "/repo", - repository: "acme/web", - provider: SourceControlProviderKind.make("bitbucket"), - host: "bitbucket.org", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("bitbucket"), { - capabilities: { - ...fakeProvider(SourceControlProviderKind.make("bitbucket")).capabilities, - actions: ["merge", "close"], - }, - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual(result.providers[0]?.actions, ["merge", "close"]); - }), -); - -it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () => - Effect.gen(function* () { - let refinementCalls = 0; - const selfHosted = project({ - id: "p1", - title: "self-hosted", - workspaceRoot: "/gitlab", - repository: "group/project", - provider: SourceControlProviderKind.make("unknown"), - host: "code.example.test", - }); - const service = yield* makeService({ - projects: [ - selfHosted, - { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/gitlab-worktree" }, - ], - providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))], - resolveHandle: ({ context }) => { - refinementCalls += 1; - assert.strictEqual(context?.remoteUrl, "https://code.example.test/group/project.git"); - return Effect.succeed({ - context: { - ...context!, - provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") }, - }, - provider: undefined as never, - }); - }, - }); - - const result = yield* service.list({ state: "open" }); - - assert.strictEqual(refinementCalls, 1); - assert.strictEqual(result.providers[0]?.host, "code.example.test"); - assert.strictEqual(result.providers[0]?.kind, "gitlab"); - }), -); - it.effect("derives a legacy repository host after refining its provider", () => Effect.gen(function* () { const current = project({ @@ -906,30 +545,6 @@ it.effect("reads nothing from a host with no implementation, but reports it", () }), ); -it.effect("asks for a whole page of a host, and for the reader's own size when given one", () => - Effect.gen(function* () { - const limits: number[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => { - limits.push(input.limit); - return Effect.succeed({ items: [], truncated: false, continues: true }); - }, - }), - ], - }); - - yield* service.list({ state: "open" }); - yield* service.list({ state: "open", limit: 10 }); - - assert.deepStrictEqual(limits, [99, 10]); - }), -); - it.effect("says where each repository carries on, and from nothing it has run out of", () => Effect.gen(function* () { const service = yield* makeService({ @@ -957,31 +572,6 @@ it.effect("says where each repository carries on, and from nothing it has run ou }), ); -it.effect("offers no continuation for a host that cannot be carried on from", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: true, - continues: false, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.isTrue(result.truncated); - assert.deepStrictEqual(result.nextCursors, {}); - }), -); - it.effect("uses a provider's raw cursor advance when it consumed malformed rows", () => Effect.gen(function* () { const service = yield* makeService({ @@ -1016,34 +606,79 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows" }), ); -it.effect("reads only the repositories it was asked to carry on with", () => +it.effect("continues Bitbucket listings through tied timestamps and terminates", () => Effect.gen(function* () { - const listed: string[] = []; - const cursors: Array = []; + const boundary = "2026-10-12T00:00:00.000Z"; + const older = "2026-10-11T00:00:00.000Z"; + const rows = [1, 2, 3, 4, 5].map((id) => ({ + id, + title: `Pull request ${id}`, + state: "OPEN", + created_on: older, + updated_on: id <= 3 ? boundary : older, + source: { branch: { name: `feat/${id}` } }, + destination: { branch: { name: "main" } }, + links: { html: { href: `https://bitbucket.org/acme/web/pull-requests/${id}` } }, + })); + const provider = yield* BitbucketPullRequestProvider.make.pipe( + Effect.provide( + BitbucketPullRequestApi.layer.pipe( + Layer.provide( + Layer.mock(BitbucketApi.BitbucketApi)({ + request: ({ url }) => { + if (url === "/user") { + return Effect.succeed({ + body: JSON.stringify({ nickname: "bilal" }), + truncated: false, + }); + } + const filter = + new URL(url, "https://api.bitbucket.org").searchParams.get("q") ?? ""; + const updatedBefore = /updated_on <= (\S+)/.exec(filter)?.[1]; + const excluded = new Set( + Array.from(filter.matchAll(/id != (\d+)/g), (match) => Number(match[1])), + ); + const values = rows.filter( + (row) => + (updatedBefore === undefined || row.updated_on <= updatedBefore) && + !excluded.has(row.id), + ); + return Effect.succeed({ body: JSON.stringify({ values }), truncated: false }); + }, + }), + ), + ), + ), + ); const service = yield* makeService({ projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), + project({ + id: "p1", + title: "web", + workspaceRoot: "/w", + repository: "acme/web", + provider: "bitbucket", + host: "bitbucket.org", + }), ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => { - listed.push(input.repository); - cursors.push(input.cursor); - return Effect.succeed({ items: [], truncated: false, continues: true }); - }, - }), - ], - }); - - const result = yield* service.list({ - state: "open", - cursors: { "github.com acme/web": "2026-07-02T00:00:00Z|99|7" }, + providers: [provider], }); - - assert.deepStrictEqual(listed, ["acme/web"]); - assert.deepStrictEqual(cursors, [{ updatedBefore: "2026-07-02T00:00:00Z", delivered: 99 }]); - assert.strictEqual(result.providers.length, 1); + let page = yield* service.list({ state: "open", limit: 2 }); + assert.deepStrictEqual( + page.entries.map((entry) => entry.number), + [1, 2], + ); + for (const expected of [[3, 4], [5]]) { + assert.isTrue(page.truncated); + assert.isNotEmpty(page.nextCursors); + page = yield* service.list({ state: "open", limit: 2, cursors: page.nextCursors }); + assert.deepStrictEqual( + page.entries.map((entry) => entry.number), + expected, + ); + } + assert.isFalse(page.truncated); + assert.deepStrictEqual(page.nextCursors, {}); }), ); @@ -1120,31 +755,6 @@ it.effect("keeps the earlier exclusions when a slice ends on the instant it bega }), ); -it.effect("refuses a continuation it did not issue, before asking any host anything", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => Effect.die("should not be read"), - }), - ], - }); - - const error = yield* Effect.flip( - service.list({ state: "open", cursors: { "github.com pingdotgg/t3code": "yesterday" } }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.strictEqual( - error.message, - "Pull request operation list failed: The list could not be carried on from where it left off.", - ); - }), -); - it.effect("calls a transient viewer failure a failed operation, not a signed-out CLI", () => Effect.gen(function* () { const service = yield* makeService({ @@ -1211,90 +821,6 @@ it.effect("reports an unusable host over a merely failing one", () => }), ); -it.effect("lists every host that has an implementation", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - project({ - id: "p2", - title: "on gitlab", - workspaceRoot: "/b", - repository: "group/sub/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-01T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { - listChangeRequests: (input) => - input.repository === "group/sub/project" - ? Effect.succeed({ - items: [changeRequest(2, "2026-07-05T00:00:00Z")], - truncated: false, - continues: true, - }) - : Effect.die("wrong repository identity"), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual( - result.entries.map((entry) => [entry.provider, entry.number]), - [ - ["gitlab", 2], - ["github", 1], - ], - ); - }), -); - -it.effect("narrows the listing to one host when asked", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - project({ - id: "p2", - title: "on gitlab", - workspaceRoot: "/b", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => Effect.die("should not be read"), - }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(2, "2026-07-05T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open", host: "gitlab.com" }); - - assert.deepStrictEqual( - result.entries.map((entry) => entry.provider), - ["gitlab"], - ); - }), -); - it.effect("tells two hosts of one kind apart in the switcher and the filter", () => Effect.gen(function* () { const service = yield* makeService({ @@ -1337,75 +863,6 @@ it.effect("tells two hosts of one kind apart in the switcher and the filter", () }), ); -it.effect("keeps one host listed when another is not set up", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - project({ - id: "p2", - title: "on gitlab", - workspaceRoot: "/b", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-01T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { - getViewer: () => - Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual( - result.entries.map((entry) => entry.provider), - ["github"], - ); - assert.deepStrictEqual( - result.providers.map((summary) => [summary.kind, summary.configured]), - [ - ["github", true], - ["gitlab", false], - ], - ); - }), -); - -it.effect("fails as unavailable only when no host can be read", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: () => - Effect.fail(unusable(SourceControlProviderKind.make("github"), "missing-tool")), - }), - ], - }); - - const error = yield* service.list({ state: "open" }).pipe(Effect.flip); - - assert.strictEqual(error._tag, "PullRequestUnavailableError"); - assert.strictEqual( - error._tag === "PullRequestUnavailableError" ? error.reason : null, - "cli-missing", - ); - }), -); - it.effect("reads a repository once when several worktrees share it", () => Effect.gen(function* () { let calls = 0; @@ -1824,6 +1281,7 @@ it.effect("keeps branch deletion on the credential that passed source repository mergeCommitAllowed: true, squashMergeAllowed: true, rebaseMergeAllowed: true, + autoMergeAllowed: true, viewerPermission: "READ", pullRequest: { viewerCanUpdate: false, @@ -1962,7 +1420,7 @@ it.effect("gates arming a merge for later exactly as it gates merging now", () = service.runAction({ ...reference, action: "enable-auto-merge", mergeMethod: "squash" }), ); assert.strictEqual(refused._tag, "PullRequestOperationError"); - assert.include(refused.message, "merged for you once it is ready"); + assert.include(refused.message, "the repository has to allow it"); assert.strictEqual(ranWith, null); const wrongStrategy = yield* Effect.flip( @@ -1973,300 +1431,76 @@ it.effect("gates arming a merge for later exactly as it gates merging now", () = }), ); -it.effect("hands the host the strategy an armed merge was asked for", () => +it.effect("keeps two hosts of one provider kind as two accounts", () => Effect.gen(function* () { - let ranWith: { readonly action: string; readonly mergeMethod?: string } | null = null; + const viewerFor: Record = { "/cloud": "bilal", "/enterprise": "b.hassan" }; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), + project({ + id: "p2", + title: "enterprise", + workspaceRoot: "/enterprise", + repository: "acme/web", + host: "github.acme.dev", + }), + ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge", "enable-auto-merge", "disable-auto-merge"], - mergeMethods: ["merge", "squash"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getViewerPermissions: () => + getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"), + listChangeRequests: () => Effect.succeed({ - actions: ["merge", "enable-auto-merge", "disable-auto-merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, }), - runAction: (input) => { - ranWith = { - action: input.action, - ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), - }; - return Effect.void; - }, }), ], }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - yield* service.runAction({ ...reference, action: "enable-auto-merge", mergeMethod: "squash" }); - assert.deepStrictEqual(ranWith, { action: "enable-auto-merge", mergeMethod: "squash" }); + const result = yield* service.list({ state: "open" }); - yield* service.runAction({ ...reference, action: "disable-auto-merge" }); - assert.deepStrictEqual(ranWith, { action: "disable-auto-merge" }); + assert.strictEqual(result.entries.length, 2); + assert.deepStrictEqual(result.viewers, { + "github.com": "bilal", + "github.acme.dev": "b.hassan", + }); + assert.deepStrictEqual(result.entries.map((entry) => entry.host).toSorted(), [ + "github.acme.dev", + "github.com", + ]); }), ); -it.effect("refuses an auto-merge the host never claimed, without asking it", () => +it.effect("stops new reads after a rate limit while leaving manual actions available", () => Effect.gen(function* () { - let ran = false; + let listCalls = 0; + let actionCalls = 0; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), + ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - runAction: () => { - ran = true; - return Effect.void; - }, - }), - ], - }); - - const error = yield* Effect.flip( - service.runAction({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - action: "enable-auto-merge", - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.isFalse(ran); - }), -); - -it.effect("refuses to resolve a conversation this viewer may not, without asking the host", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewerPermissions: () => - Effect.succeed({ - actions: ["merge", "ready", "draft", "close", "reopen"], - comment: true, - resolve: false, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }), - setThreadResolution: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.setThreadResolution({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - threadId: "t1", - resolved: true, - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "to resolve a review conversation."); - }), -); - -it.effect("asks nobody what the viewer may do when the host cannot do it at all", () => - Effect.gen(function* () { - let asked = false; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge", "close"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getViewerPermissions: () => { - asked = true; - return Effect.die("must not be called"); - }, - }), - ], - }); - - yield* Effect.flip( - service.runAction({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - action: "reopen", - }), - ); - - assert.isFalse(asked); - }), -); - -it.effect("refuses a comment on a host that cannot post one", () => - Effect.gen(function* () { - let posted = false; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: false, - comment: false, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - comment: () => { - posted = true; - return Effect.void; - }, - }), - ], - }); - - const error = yield* Effect.flip( - service.comment({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - body: "Looks good.", - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.isFalse(posted); - }), -); - -it.effect("keeps two hosts of one provider kind as two accounts", () => - Effect.gen(function* () { - const viewerFor: Record = { "/cloud": "bilal", "/enterprise": "b.hassan" }; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), - project({ - id: "p2", - title: "enterprise", - workspaceRoot: "/enterprise", - repository: "acme/web", - host: "github.acme.dev", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"), - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.strictEqual(result.entries.length, 2); - assert.deepStrictEqual(result.viewers, { - "github.com": "bilal", - "github.acme.dev": "b.hassan", - }); - assert.deepStrictEqual(result.entries.map((entry) => entry.host).toSorted(), [ - "github.acme.dev", - "github.com", - ]); - }), -); - -it.effect("reports repositories on a host that could not be read", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), - project({ - id: "p2", - title: "enterprise", - workspaceRoot: "/enterprise", - repository: "acme/api", - host: "github.acme.dev", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: (input) => - input.cwd === "/cloud" - ? Effect.succeed("bilal") - : Effect.fail(unusable(SourceControlProviderKind.make("github"), "unauthenticated")), - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.strictEqual(result.entries.length, 1); - assert.deepStrictEqual( - result.errors.map((error) => error.projectId), - ["p2"], - ); - }), -); - -it.effect("stops new reads after a rate limit while leaving manual actions available", () => - Effect.gen(function* () { - let listCalls = 0; - let actionCalls = 0; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.sync(() => { - listCalls += 1; - }).pipe( - Effect.andThen( - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "listChangeRequests", - reason: "rate-limited", - detail: "GitHub API rate limit exceeded.", - }), - ), - ), - ), - runAction: () => - Effect.sync(() => { - actionCalls += 1; - }), + listChangeRequests: () => + Effect.sync(() => { + listCalls += 1; + }).pipe( + Effect.andThen( + Effect.fail( + new PullRequestProviderError({ + provider: SourceControlProviderKind.make("github"), + operation: "listChangeRequests", + reason: "rate-limited", + detail: "GitHub API rate limit exceeded.", + }), + ), + ), + ), + runAction: () => + Effect.sync(() => { + actionCalls += 1; + }), }), ], }); @@ -2287,272 +1521,33 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail }), ); -for (const [providerName, host] of [ +for (const [] of [ ["github", "github.com"], ["gitlab", "gitlab.com"], ["forgejo", "code.example.test"], ["bitbucket", "bitbucket.org"], ["azure-devops", "dev.azure.com"], ] as const) { - const provider = SourceControlProviderKind.make(providerName); - it.effect.each(["detail", "checks"] as const)( - `shares fresh ${provider} %s reads and respects rate-limit resets`, - (read) => - Effect.gen(function* () { - let calls = 0; - let limited = false; - const repository = provider === "azure-devops" ? "web" : "acme/web"; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/repo", repository, provider, host }), - ], - providers: [ - fakeProvider(provider, { - [read === "detail" ? "getChangeRequest" : "getChangeRequestChecks"]: () => - Effect.gen(function* () { - calls += 1; - if (limited) { - return yield* new PullRequestProviderError({ - provider, - operation: "getChangeRequest", - reason: "rate-limited", - detail: "Retry after the reset.", - retryAt: (yield* Clock.currentTimeMillis) + 120_000, - }); - } - return hostedChangeRequest("current details"); - }), - }), - ], - }); - const reference = { - projectId: "p1" as ProjectId, - repository, - number: 1, - allowStale: false, - }; - const request: Effect.Effect = - service[read](reference); - yield* Effect.all([request, request], { concurrency: 2 }); - assert.strictEqual(calls, 1); - yield* TestClock.adjust("61 seconds"); - limited = true; - assert.strictEqual((yield* Effect.exit(request))._tag, "Failure"); - assert.strictEqual(calls, 2); - yield* TestClock.adjust("45 seconds"); - assert.strictEqual((yield* Effect.exit(request))._tag, "Failure"); - assert.strictEqual(calls, 2); - yield* TestClock.adjust("75 seconds"); - limited = false; - const recovered = yield* service[read](reference); - assert.strictEqual(recovered?.state, "open"); - assert.strictEqual(calls, 3); - yield* service.invalidate({ reference }); - yield* service[read](reference); - assert.strictEqual(calls, 4); - }), - ); } -it.effect("does not fall back to full detail when checks are unsupported", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/repo", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => Effect.die("Unexpected full detail read"), - }), - ], - }); - assert.isNull( - yield* service.checks({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }), - ); - }), -); - -it.effect("uses a manual rate limit to pause later reads", () => +it.effect("refuses a repository that does not belong to the requested project", () => Effect.gen(function* () { - let listCalls = 0; const service = yield* makeService({ projects: [ - project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.sync(() => { - listCalls += 1; - return { items: [], truncated: false, continues: true }; - }), - runAction: () => - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "runAction", - reason: "rate-limited", - detail: "GitHub API rate limit exceeded.", - }), - ), - }), + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], + providers: [fakeProvider(SourceControlProviderKind.make("github"))], }); - yield* Effect.flip( - service.runAction({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - action: "close", - }), - ); - const error = yield* Effect.flip(service.list({ state: "open", involvement: "all" })); + const error = yield* service + .diff({ projectId: "p1" as ProjectId, repository: "attacker/repo", number: 1 }) + .pipe(Effect.flip); - assert.strictEqual(listCalls, 0); assert.strictEqual(error._tag, "PullRequestOperationError"); }), ); -it.effect("flags a review request for the viewer but not on their own change request", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.succeed({ - items: [ - { ...changeRequest(1, "2026-07-02T00:00:00Z"), reviewRequestLogins: ["Bilal"] }, - { - ...changeRequest(2, "2026-07-02T00:00:00Z"), - author: { login: "bilal", name: null, avatarUrl: null }, - reviewRequestLogins: ["bilal"], - }, - ], - truncated: false, - continues: true, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual( - result.entries.map((entry) => entry.viewerReviewRequested), - [true, false], - ); - }), -); - -it.effect("refuses a repository that does not belong to the requested project", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], - }); - - const error = yield* service - .diff({ projectId: "p1" as ProjectId, repository: "attacker/repo", number: 1 }) - .pipe(Effect.flip); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("caches stack membership separately from action details", () => - Effect.gen(function* () { - const reads: Array = []; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestStack: (input) => - Effect.sync(() => { - reads.push(input.includeDetails === true); - return { - id: "9", - number: 3, - url: "https://github.com/acme/web/stacks/3", - base: "main", - layers: [ - { - number: 7, - headBranch: "a", - state: "open" as const, - ...(input.includeDetails ? { title: "First layer", headSha: "abc" } : {}), - }, - ], - }; - }), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 7 }; - yield* service.stack(reference, { includeDetails: false }); - yield* service.stack(reference, { includeDetails: false }); - const detail = yield* service.stack(reference); - yield* service.stack(reference); - assert.deepStrictEqual(reads, [false, true]); - assert.strictEqual(detail?.layers[0]?.headSha, "abc"); - yield* service.invalidate({ reference }); - yield* service.stack(reference, { includeDetails: false }); - yield* service.stack(reference); - assert.deepStrictEqual(reads, [false, true, false, true]); - }), -); - -it.effect("reads a host-native stack through the provider and null where it has none", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestStack: () => - Effect.succeed({ - id: "9", - number: 3, - url: "https://github.com/acme/web/stacks/3", - base: "main", - layers: [ - { number: 7, headBranch: "a", state: "open" as const }, - { number: 8, headBranch: "b", state: "open" as const }, - ], - }), - }), - ], - }); - - const stack = yield* service.stack({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 7, - }); - assert.deepStrictEqual( - stack?.layers.map((layer) => layer.number), - [7, 8], - ); - - const withoutStacks = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], - }); - assert.isNull( - yield* withoutStacks.stack({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 7, - }), - ); - }), -); - -it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after refinement", () => +it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after refinement", () => Effect.gen(function* () { for (const provider of ["forgejo", "unknown"] as const) { const seen: string[] = []; @@ -2644,1323 +1639,321 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after }), ); -it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "http", - title: "http", - workspaceRoot: "/http", - repository: "team/repo", - provider: SourceControlProviderKind.make("forgejo"), - host: "code.example", - remoteUrl: "http://code.example:4000/team/repo.git", - }), - ], - providers: [fakeProvider(SourceControlProviderKind.make("forgejo"))], - }); - const failure = yield* service - .summary( - { - projectId: "http" as ProjectId, - host: "code.example:3000", - repository: "team/repo", - number: 42, - }, - { recoverTransientFailure: false }, - ) - .pipe(Effect.flip); - assert.strictEqual(failure._tag, "PullRequestUnavailableError"); - }), -); - -it.effect("resolves a project's repository identity when its shell has none cached", () => - Effect.gen(function* () { - const resolved = project({ - id: "web", - title: "web", - workspaceRoot: "/web", - repository: "acme/web", - }); - const service = yield* makeService({ - projects: [{ ...resolved, repositoryIdentity: null }], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: () => Effect.succeed(changeRequest(7, "2026-07-02T00:00:00Z")), - }), - ], - resolveRepositoryIdentity: () => Effect.succeed(resolved.repositoryIdentity ?? null), - }); - - const summary = yield* service.summary( - { projectId: "web" as ProjectId, host: "github.com", repository: "acme/web", number: 7 }, - { recoverTransientFailure: false }, - ); - - assert.strictEqual(summary.number, 7); - }), -); - -it.effect("routes a hosted reference to another repository through a project on that host", () => - Effect.gen(function* () { - const seen: Array<{ cwd: string; repository: string; host: string }> = []; - const service = yield* makeService({ - projects: [ - project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - }), - ], - }); - - const summary = yield* service.summary( - { projectId: "frontend" as ProjectId, host: "github.com", repository: "acme/api", number: 7 }, - { recoverTransientFailure: false }, - ); - - assert.strictEqual(summary.number, 7); - assert.deepStrictEqual(seen, [{ cwd: "/web", repository: "acme/api", host: "github.com" }]); - }), -); - -it.effect("routes Azure reads and writes through the requested organization's checkout", () => - Effect.gen(function* () { - const seen: string[] = []; - const service = yield* makeService({ - projects: ["org-a", "org-b"].map((organization) => - project({ - id: organization, - title: organization, - workspaceRoot: `/${organization}`, - repository: `${organization}/project/_git/web`, - provider: SourceControlProviderKind.make("azure-devops"), - host: "dev.azure.com", - }), - ), - providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push(`read ${input.cwd} ${input.repository}`); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - runAction: (input) => - Effect.sync(() => { - seen.push(`write ${input.cwd} ${input.repository}`); - }), - }), - ], - }); - const reference = { - projectId: "org-a" as ProjectId, - host: "dev.azure.com", - repository: "org-b/project/_git/web", - number: 7, - }; - yield* service.summary(reference, { recoverTransientFailure: false }); - yield* service.runAction({ ...reference, action: "merge" }); - assert.deepStrictEqual(seen, ["read /org-b web", "write /org-b web", "read /org-b web"]); - }), -); - -it.effect.each([ - { - host: "ssh.dev.azure.com", - repository: "v3/org-b/project/web", - remoteUrl: "git@ssh.dev.azure.com:v3/org-b/project/web", - }, - { - host: "vs-ssh.visualstudio.com", - repository: "v3/org-b/project/web", - remoteUrl: "git@vs-ssh.visualstudio.com:v3/org-b/project/web", - }, - { - host: "org-b.visualstudio.com", - repository: "DefaultCollection/project/_git/web", - remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web", - }, -])("routes Azure URL reads and writes through a $host checkout", (checkout) => - Effect.gen(function* () { - const seen: string[] = []; - const target = project({ - id: "target", - title: "target", - workspaceRoot: "/target", - provider: SourceControlProviderKind.make("azure-devops"), - ...checkout, - }); - const service = yield* makeService({ - projects: [ - ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) => - project({ - id: repository, - title: repository, - workspaceRoot: `/${repository}`, - provider: SourceControlProviderKind.make("azure-devops"), - host: "dev.azure.com", - repository, - }), - ), - target, - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push(`read ${input.cwd} ${input.repository}`); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - runAction: (input) => - Effect.sync(() => { - seen.push(`write ${input.cwd} ${input.repository}`); - }), - }), - ], - }); - const reference = { - projectId: "org-a/project/_git/web" as ProjectId, - host: "dev.azure.com", - repository: "org-b/project/_git/web", - number: 7, - }; - yield* service.summary(reference, { recoverTransientFailure: false }); - yield* service.runAction({ ...reference, action: "merge" }); - assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]); - }), -); - -it.effect("refuses Azure cross-organization reads and writes without its checkout", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "org-a", - title: "org-a", - workspaceRoot: "/org-a", - repository: "org-a/project/_git/web", - provider: SourceControlProviderKind.make("azure-devops"), - host: "dev.azure.com", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { - getChangeRequestSummary: () => Effect.die("must not read the wrong organization"), - runAction: () => Effect.die("must not modify the wrong organization"), - }), - ], - }); - const reference = { - projectId: "org-a" as ProjectId, - host: "dev.azure.com", - repository: "org-b/project/_git/web", - number: 7, - }; - const readError = yield* Effect.flip( - service.summary(reference, { recoverTransientFailure: false }), - ); - const writeError = yield* Effect.flip(service.runAction({ ...reference, action: "close" })); - assert.strictEqual(readError._tag, "PullRequestUnavailableError"); - assert.strictEqual(writeError._tag, "PullRequestUnavailableError"); - }), -); - -it.effect.each([ - ["github.com", "github"], - ["bitbucket.org", "bitbucket"], - ["codeberg.org", "forgejo"], -] as const)("reads linked PR summaries and stacks on %s without a checkout", ([host, kind]) => - Effect.gen(function* () { - const seen: Array<{ cwd: string; repository: string; host: string }> = []; - const service = yield* makeService({ - projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })], - providers: [ - fakeProvider(SourceControlProviderKind.make(kind), { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - getChangeRequestStack: (input) => - Effect.sync(() => { - seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); - return null; - }), - }), - ], - }); - const reference = { - projectId: "inbox" as ProjectId, - host, - repository: "acme/api", - number: 7, - }; - const summary = yield* service.summary(reference, { recoverTransientFailure: false }); - const stack = yield* service.stack(reference, { includeDetails: false }); - assert.strictEqual(summary.title, "Change request 7"); - assert.strictEqual(summary.state, "open"); - assert.strictEqual(summary.projectId, reference.projectId); - assert.strictEqual(stack, null); - assert.deepStrictEqual(seen, [ - { cwd: "/home/alex", repository: "acme/api", host }, - { cwd: "/home/alex", repository: "acme/api", host }, - ]); - }), -); - -it.effect("prefers a matching checkout over a non-repository project's directory", () => - Effect.gen(function* () { - const seen: string[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" }), - project({ id: "repo", title: "API", workspaceRoot: "/api", repository: "acme/api" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push(input.cwd); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - }), - ], - }); - yield* service.summary( - { - projectId: "inbox" as ProjectId, - host: "github.com", - repository: "acme/api", - number: 7, - }, - { recoverTransientFailure: false }, - ); - assert.deepStrictEqual(seen, ["/api"]); - }), -); - -it.effect("requires an explicit host for a non-repository project's linked PR", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], - }); - const error = yield* service - .summary( - { - projectId: "inbox" as ProjectId, - repository: "acme/api", - number: 7, - }, - { recoverTransientFailure: false }, - ) - .pipe(Effect.flip); - assert.strictEqual(error._tag, "PullRequestUnavailableError"); - }), -); - -it.effect("refuses a public host reference without a selected project directory", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], - }); - const error = yield* service - .summary( - { - projectId: "missing" as ProjectId, - host: "github.com", - repository: "acme/api", - number: 7, - }, - { recoverTransientFailure: false }, - ) - .pipe(Effect.flip); - assert.strictEqual(error._tag, "PullRequestUnavailableError"); - }), -); - -it.effect( - "requires a checkout for GitLab because its adapter does not explicitly target the host", - () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })], - providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))], - }); - const error = yield* service - .summary( - { - projectId: "inbox" as ProjectId, - host: "gitlab.com", - repository: "acme/api", - number: 7, - }, - { recoverTransientFailure: false }, - ) - .pipe(Effect.flip); - assert.strictEqual(error._tag, "PullRequestUnavailableError"); - }), -); - -it.effect("refuses a hosted reference when its provider is unavailable", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), - ], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], - }); - - const error = yield* service - .summary( - { - projectId: "frontend" as ProjectId, - host: "gitlab.com", - repository: "acme/api", - number: 7, - }, - { recoverTransientFailure: false }, - ) - .pipe(Effect.flip); - - assert.strictEqual(error._tag, "PullRequestUnavailableError"); - }), -); - -it.effect("refuses a diff on a host that cannot produce one", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on azure", - workspaceRoot: "/a", - repository: "org/project", - provider: SourceControlProviderKind.make("azure-devops"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { - capabilities: { - diff: false, - comment: true, - actions: ["merge", "close"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getDiff: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* service - .diff({ projectId: "p1" as ProjectId, repository: "org/project", number: 1 }) - .pipe(Effect.flip); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("rejects an empty comment before reaching the host", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - comment: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* service - .comment({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - body: " ", - }) - .pipe(Effect.flip); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("refuses a verdict the host never claimed, without asking the provider", () => - Effect.gen(function* () { - let submitted = false; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: { - inlineComment: true, - reply: true, - resolve: true, - verdicts: ["comment", "approve"], - }, - reviewers: FULL_REVIEWERS, - }, - submitReview: () => { - submitted = true; - return Effect.void; - }, - }), - ], - }); - - const error = yield* Effect.flip( - service.submitReview({ - projectId: "p1" as ProjectId, - repository: "group/project", - number: 1, - verdict: "request-changes", - body: "no", - comments: [], - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.isFalse(submitted); - }), -); - -it.effect("refuses line comments on a host that takes only a summary", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: { inlineComment: false, reply: false, resolve: false, verdicts: ["comment"] }, - reviewers: FULL_REVIEWERS, - }, - submitReview: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.submitReview({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - verdict: "comment", - body: "", - comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 1 }, body: "nit" }], - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect( - "refuses a review with neither a summary nor a comment, but lets an approval through", - () => - Effect.gen(function* () { - let approved = false; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "t3code", - workspaceRoot: "/a", - repository: "pingdotgg/t3code", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - submitReview: () => { - approved = true; - return Effect.void; - }, - }), - ], - }); - const reference = { - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - }; - - const error = yield* Effect.flip( - service.submitReview({ ...reference, verdict: "comment", body: " ", comments: [] }), - ); - assert.strictEqual(error._tag, "PullRequestOperationError"); - - yield* service.submitReview({ ...reference, verdict: "approve", body: "", comments: [] }); - assert.isTrue(approved); - }), -); - -it.effect("refuses to resolve a conversation on a host that cannot", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: { inlineComment: true, reply: false, resolve: false, verdicts: ["comment"] }, - reviewers: FULL_REVIEWERS, - }, - setThreadResolution: () => Effect.die("must not be called"), - replyToThread: () => Effect.die("must not be called"), - }), - ], - }); - const reference = { - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - }; - - const resolveError = yield* Effect.flip( - service.setThreadResolution({ ...reference, threadId: "t1", resolved: true }), - ); - const replyError = yield* Effect.flip( - service.replyToThread({ ...reference, threadId: "t1", body: "hi" }), - ); - - assert.strictEqual(resolveError._tag, "PullRequestOperationError"); - assert.strictEqual(replyError._tag, "PullRequestOperationError"); - }), -); - -it.effect("refuses to react on a host with no reactions", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: false, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - setReaction: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.setReaction({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - content: "heart", - reacted: true, - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("refuses to react on a host whose capabilities omit reactions entirely", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - setReaction: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.setReaction({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - content: "heart", - reacted: true, - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("passes a reaction through with its subject id on a host that has them", () => - Effect.gen(function* () { - let received: { - readonly subjectId: string | undefined; - readonly content: string; - readonly reacted: boolean; - } | null = null; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - setReaction: (input) => { - received = { - subjectId: input.subjectId, - content: input.content, - reacted: input.reacted, - }; - return Effect.void; - }, - }), - ], - }); - - yield* service.setReaction({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - subjectId: "IC_1", - content: "heart", - reacted: true, - }); - - assert.deepStrictEqual(received, { subjectId: "IC_1", content: "heart", reacted: true }); - }), -); - -it.effect("invalidates the cached activity after reacting, like the other mutations", () => - Effect.gen(function* () { - let activityCalls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestActivity: () => { - activityCalls += 1; - return Effect.succeed({ - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }); - }, - }), - ], - }); - - yield* service.refreshAfterTurn(reference.projectId); - const previousRefresh = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)); - yield* service.activity(reference); - assert.strictEqual(activityCalls, 1); - - yield* service.setReaction({ ...reference, content: "heart", reacted: true }); - assert.isAbove( - Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)), - previousRefresh, - ); - yield* service.activity(reference); - - assert.strictEqual(activityCalls, 2); - }), -); - -it.effect("refuses an empty reply before it reaches the host", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - replyToThread: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.replyToThread({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - threadId: "t1", - body: " ", - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("refuses a merge strategy the host does not offer", () => - Effect.gen(function* () { - let ranWith: string | null = null; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge", "squash"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), - runAction: (input) => { - ranWith = input.mergeMethod ?? "merge"; - return Effect.void; - }, - }), - ], - }); - const reference = { - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - }; - - const error = yield* Effect.flip( - service.runAction({ ...reference, action: "merge", mergeMethod: "rebase" }), - ); - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.strictEqual(ranWith, null); - - yield* service.runAction({ ...reference, action: "merge", mergeMethod: "squash" }); - assert.strictEqual(ranWith, "squash"); - }), -); - -it.effect("hands the provider the host its repository lives on", () => - Effect.gen(function* () { - const hosts: string[] = []; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "enterprise", - workspaceRoot: "/a", - repository: "acme/web", - host: "github.acme.dev", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => { - hosts.push(input.host); - return Effect.succeed({ items: [], truncated: false, continues: true }); - }, - }), - ], - }); - - yield* service.list({ state: "open" }); - - assert.deepStrictEqual(hosts, ["github.acme.dev"]); - }), -); - -it.effect("asks every host the reader's search, rather than filtering what came back", () => - Effect.gen(function* () { - const asked: Array = []; - const listing = (input: { readonly query?: string | undefined }) => { - asked.push(input.query); - return Effect.succeed({ items: [], truncated: false, continues: true }); - }; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - project({ - id: "p2", - title: "on gitlab", - workspaceRoot: "/b", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: listing }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: listing }), - ], - }); - - yield* service.list({ state: "open", query: "pull requests page" }); - - assert.deepStrictEqual(asked, ["pull requests page", "pull requests page"]); - }), -); - -it.effect("asks for no search when the reader has typed nothing", () => - Effect.gen(function* () { - const asked: Array = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => { - asked.push(input.query); - return Effect.succeed({ items: [], truncated: false, continues: true }); - }, - }), - ], - }); - - yield* service.list({ state: "open" }); - - assert.deepStrictEqual(asked, [undefined]); - }), -); - -it.effect("asks another checkout who is signed in when the first one cannot answer", () => - Effect.gen(function* () { - const asked: string[] = []; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "t3code (stale worktree)", - workspaceRoot: "/gone", - repository: "pingdotgg/t3code", - }), - project({ - id: "p2", - title: "t3code", - workspaceRoot: "/healthy", - repository: "pingdotgg/t3code", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: (input) => { - asked.push(input.cwd); - return input.cwd === "/gone" - ? Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getViewer", - reason: "failed", - detail: "not a git repository", - }), - ) - : Effect.succeed("bilal"); - }, - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: false, - continues: true, - }), +it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "http", + title: "http", + workspaceRoot: "/http", + repository: "team/repo", + provider: SourceControlProviderKind.make("forgejo"), + host: "code.example", + remoteUrl: "http://code.example:4000/team/repo.git", }), ], + providers: [fakeProvider(SourceControlProviderKind.make("forgejo"))], }); - - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual(asked, ["/gone", "/healthy"]); - assert.strictEqual(result.entries.length, 1); - assert.strictEqual(result.providers[0]?.configured, true); + const failure = yield* service + .summary( + { + projectId: "http" as ProjectId, + host: "code.example:3000", + repository: "team/repo", + number: 42, + }, + { recoverTransientFailure: false }, + ) + .pipe(Effect.flip); + assert.strictEqual(failure._tag, "PullRequestUnavailableError"); }), ); -it.effect("refuses to ask for a review on a host that cannot, before any call is made", () => +it.effect("resolves a project's repository identity when its shell has none cached", () => Effect.gen(function* () { - let asked = false; + const resolved = project({ + id: "web", + title: "web", + workspaceRoot: "/web", + repository: "acme/web", + }); const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [{ ...resolved, repositoryIdentity: null }], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: { request: false, listCandidates: false }, - }, - getViewerPermissions: () => { - asked = true; - return Effect.die("must not be called"); - }, - setReviewerRequest: () => Effect.die("must not be called"), + getChangeRequestSummary: () => Effect.succeed(changeRequest(7, "2026-07-02T00:00:00Z")), }), ], + resolveRepositoryIdentity: () => Effect.succeed(resolved.repositoryIdentity ?? null), }); - const error = yield* Effect.flip( - service.requestReviewers({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - reviewers: [{ id: "octocat", kind: "user" }], - requested: true, - }), + const summary = yield* service.summary( + { projectId: "web" as ProjectId, host: "github.com", repository: "acme/web", number: 7 }, + { recoverTransientFailure: false }, ); - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "cannot ask somebody for a review."); - assert.isFalse(asked); + assert.strictEqual(summary.number, 7); }), ); -it.effect("refuses the candidate list on a host that has no such list to give", () => +it.effect("routes a hosted reference to another repository through a project on that host", () => Effect.gen(function* () { + const seen: Array<{ cwd: string; repository: string; host: string }> = []; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), + ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: false, - comment: false, - actions: ["merge"], - mergeMethods: ["merge"], - search: false, - reactions: true, - review: FULL_REVIEW, - reviewers: { request: true, listCandidates: false }, - }, - listReviewerCandidates: () => Effect.die("must not be called"), + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), }), ], }); - const error = yield* Effect.flip( - service.reviewerCandidates({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - }), + const summary = yield* service.summary( + { projectId: "frontend" as ProjectId, host: "github.com", repository: "acme/api", number: 7 }, + { recoverTransientFailure: false }, ); - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "cannot say who may review a change request."); + assert.strictEqual(summary.number, 7); + assert.deepStrictEqual(seen, [{ cwd: "/web", repository: "acme/api", host: "github.com" }]); }), ); -it.effect("refuses a review request this viewer may not make, and says what access it takes", () => +it.effect("routes Azure reads and writes through the requested organization's checkout", () => Effect.gen(function* () { - let sent = false; + const seen: string[] = []; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: ["org-a", "org-b"].map((organization) => + project({ + id: organization, + title: organization, + workspaceRoot: `/${organization}`, + repository: `${organization}/project/_git/web`, + provider: SourceControlProviderKind.make("azure-devops"), + host: "dev.azure.com", + }), + ), providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewerPermissions: () => - Effect.succeed({ - actions: ["ready", "draft", "close", "reopen"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: false, + fakeProvider(SourceControlProviderKind.make("azure-devops"), { + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(`read ${input.cwd} ${input.repository}`); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), + runAction: (input) => + Effect.sync(() => { + seen.push(`write ${input.cwd} ${input.repository}`); }), - setReviewerRequest: () => { - sent = true; - return Effect.void; - }, }), ], }); - - const error = yield* Effect.flip( - service.requestReviewers({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - reviewers: [{ id: "octocat", kind: "user" }], - requested: true, - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "You need write access on this repository to ask for a review."); - assert.isFalse(sent); + const reference = { + projectId: "org-a" as ProjectId, + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + }; + yield* service.summary(reference, { recoverTransientFailure: false }); + yield* service.runAction({ ...reference, action: "merge" }); + assert.deepStrictEqual(seen, ["read /org-b web", "write /org-b web", "read /org-b web"]); }), ); -it.effect("keeps the menu from a viewer who may not ask, which is all it is for", () => +it.effect.each([ + { + host: "ssh.dev.azure.com", + repository: "v3/org-b/project/web", + remoteUrl: "git@ssh.dev.azure.com:v3/org-b/project/web", + }, + { + host: "vs-ssh.visualstudio.com", + repository: "v3/org-b/project/web", + remoteUrl: "git@vs-ssh.visualstudio.com:v3/org-b/project/web", + }, + { + host: "org-b.visualstudio.com", + repository: "DefaultCollection/project/_git/web", + remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web", + }, +])("routes Azure URL reads and writes through a $host checkout", (checkout) => Effect.gen(function* () { + const seen: string[] = []; + const target = project({ + id: "target", + title: "target", + workspaceRoot: "/target", + provider: SourceControlProviderKind.make("azure-devops"), + ...checkout, + }); const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) => + project({ + id: repository, + title: repository, + workspaceRoot: `/${repository}`, + provider: SourceControlProviderKind.make("azure-devops"), + host: "dev.azure.com", + repository, + }), + ), + target, + ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewerPermissions: () => - Effect.succeed({ - actions: [], - comment: true, - resolve: false, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: false, + fakeProvider(SourceControlProviderKind.make("azure-devops"), { + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(`read ${input.cwd} ${input.repository}`); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), + runAction: (input) => + Effect.sync(() => { + seen.push(`write ${input.cwd} ${input.repository}`); }), - listReviewerCandidates: () => Effect.die("must not be called"), }), ], }); - - const error = yield* Effect.flip( - service.reviewerCandidates({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - }), - ); - - assert.include(error.message, "You need write access on this repository to ask for a review."); + const reference = { + projectId: "org-a/project/_git/web" as ProjectId, + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + }; + yield* service.summary(reference, { recoverTransientFailure: false }); + yield* service.runAction({ ...reference, action: "merge" }); + assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]); }), ); -it.effect("hands the host's own candidate list back, and asks for it with the change request", () => +it.effect("refuses Azure cross-organization reads and writes without its checkout", () => Effect.gen(function* () { - let askedFor: number | null = null; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + project({ + id: "org-a", + title: "org-a", + workspaceRoot: "/org-a", + repository: "org-a/project/_git/web", + provider: SourceControlProviderKind.make("azure-devops"), + host: "dev.azure.com", + }), + ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listReviewerCandidates: (input) => { - askedFor = input.number; - return Effect.succeed({ - candidates: [ - { - id: "octocat", - kind: "user", - login: "octocat", - name: null, - avatarUrl: null, - isRequested: true, - }, - ], - truncated: false, - continues: true, - }); - }, + fakeProvider(SourceControlProviderKind.make("azure-devops"), { + getChangeRequestSummary: () => Effect.die("must not read the wrong organization"), + runAction: () => Effect.die("must not modify the wrong organization"), }), ], }); - - const list = yield* service.reviewerCandidates({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 4, - }); - - assert.strictEqual(askedFor, 4); - assert.deepStrictEqual( - list.candidates.map((candidate) => candidate.login), - ["octocat"], + const reference = { + projectId: "org-a" as ProjectId, + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + }; + const readError = yield* Effect.flip( + service.summary(reference, { recoverTransientFailure: false }), ); + const writeError = yield* Effect.flip(service.runAction({ ...reference, action: "close" })); + assert.strictEqual(readError._tag, "PullRequestUnavailableError"); + assert.strictEqual(writeError._tag, "PullRequestUnavailableError"); }), ); -it.effect("refuses a label change on a host that has not said it takes one", () => +it.effect("prefers a matching checkout over a non-repository project's directory", () => Effect.gen(function* () { - let changed = false; + const seen: string[] = []; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" }), + project({ id: "repo", title: "API", workspaceRoot: "/api", repository: "acme/api" }), + ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - setLabels: () => { - changed = true; - return Effect.void; - }, + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(input.cwd); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), }), ], }); - - const error = yield* Effect.flip( - service.setLabels({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - labels: ["bug"], - applied: true, - }), + yield* service.summary( + { + projectId: "inbox" as ProjectId, + host: "github.com", + repository: "acme/api", + number: 7, + }, + { recoverTransientFailure: false }, ); + assert.deepStrictEqual(seen, ["/api"]); + }), +); - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "cannot change the labels"); - assert.isFalse(changed); +// regression: #17167 refresh linked PRs without a checkout +it.effect("requires an explicit host for a non-repository project's linked PR", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })], + providers: [fakeProvider(SourceControlProviderKind.make("github"))], + }); + const error = yield* service + .summary( + { + projectId: "inbox" as ProjectId, + repository: "acme/api", + number: 7, + }, + { recoverTransientFailure: false }, + ) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PullRequestUnavailableError"); }), ); -it.effect("refuses a label change this viewer may not make, and says what access it takes", () => +it.effect("refuses a review request this viewer may not make, and says what access it takes", () => Effect.gen(function* () { - let changed = false; + let sent = false; const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, - labels: true, - }, getViewerPermissions: () => Effect.succeed({ - actions: [], + actions: ["ready", "draft", "close", "reopen"], comment: true, - resolve: false, + resolve: true, verdicts: ["comment", "approve", "request-changes"], requestReviewers: false, - labels: false, }), - listLabelCandidates: () => Effect.die("must not be called"), - setLabels: () => { - changed = true; + setReviewerRequest: () => { + sent = true; return Effect.void; }, }), ], }); - const listError = yield* Effect.flip( - service.labelCandidates({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }), - ); - assert.include(listError.message, "You need triage access on this repository"); - const error = yield* Effect.flip( - service.setLabels({ + service.requestReviewers({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1, - labels: ["bug"], - applied: true, + reviewers: [{ id: "octocat", kind: "user" }], + requested: true, }), ); - assert.include(error.message, "You need triage access on this repository"); - assert.isFalse(changed); - }), -); - -it.effect("hands a label change to the host, and reads the labels back for the menu", () => - Effect.gen(function* () { - let received: { labels: ReadonlyArray; applied: boolean } | null = null; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, - labels: true, - }, - listLabelCandidates: () => - Effect.succeed({ - candidates: [{ name: "bug", color: null, description: null, isApplied: false }], - truncated: false, - }), - setLabels: (input) => { - received = { labels: input.labels, applied: input.applied }; - return Effect.void; - }, - }), - ], - }); - - const list = yield* service.labelCandidates({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 4, - }); - assert.deepStrictEqual( - list.candidates.map((label) => label.name), - ["bug"], - ); - yield* service.setLabels({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 4, - labels: ["bug"], - applied: false, - }); - assert.deepStrictEqual(received, { labels: ["bug"], applied: false }); - }), -); - -it.effect("answers a repeated listing from cache, and concurrent readers share one request", () => - Effect.gen(function* () { - let hostCalls = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => { - hostCalls += 1; - return Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: false, - continues: false, - }); - }, - }), - ], - }); - - yield* Effect.all([service.list({ state: "open" }), service.list({ state: "open" })], { - concurrency: "unbounded", - }); - yield* service.list({ state: "open" }); - assert.strictEqual(hostCalls, 1); - - yield* service.list({ state: "all" }); - assert.strictEqual(hostCalls, 2); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "You need write access on this repository to ask for a review."); + assert.isFalse(sent); }), ); @@ -4065,105 +2058,6 @@ it.effect("shares one cold viewer lookup across distinct concurrent lists", () = }), ); -it.effect("uses five host reads for the normal indexed-repository page workflow", () => - Effect.gen(function* () { - let viewerCalls = 0; - let searchCalls = 0; - let fallbackCalls = 0; - let statsCalls = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: () => - Effect.sync(() => { - viewerCalls += 1; - return "bilal"; - }), - listChangeRequestsAcross: (input) => - Effect.sync(() => { - searchCalls += 1; - return { - items: - input.involvement === "all" - ? [batchedChangeRequest(1, "acme/web", "2026-07-02T00:00:00Z")] - : [], - truncated: false, - }; - }), - listChangeRequests: () => - Effect.sync(() => { - fallbackCalls += 1; - return { items: [], truncated: false, continues: true }; - }), - listChangeRequestStats: () => - Effect.sync(() => { - statsCalls += 1; - return [{ repository: "acme/web", number: 1, additions: 3, deletions: 1 }]; - }), - }), - ], - }); - - const baseline = yield* service.list({ state: "open", involvement: "all" }); - yield* Effect.all( - [ - service.list({ state: "open", involvement: "authored" }), - service.list({ state: "open", involvement: "reviewing" }), - ], - { concurrency: "unbounded" }, - ); - yield* service.listStats({ - refs: baseline.entries.map(({ projectId, repository, number }) => ({ - projectId, - repository, - number, - })), - }); - - assert.deepStrictEqual( - { viewerCalls, searchCalls, fallbackCalls, statsCalls }, - { viewerCalls: 1, searchCalls: 3, fallbackCalls: 0, statsCalls: 1 }, - ); - }), -); - -it.effect("returns the refreshed listing on the first read after its cache expires", () => - Effect.gen(function* () { - let hostCalls = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => { - hostCalls += 1; - return Effect.succeed({ - items: [changeRequest(hostCalls, "2026-07-02T00:00:00Z")], - truncated: false, - continues: false, - }); - }, - }), - ], - }); - - const first = yield* service.list({ state: "open" }); - assert.deepStrictEqual( - first.entries.map((entry) => entry.number), - [1], - ); - - yield* TestClock.adjust("31 seconds"); - const refreshed = yield* service.list({ state: "open" }); - - assert.strictEqual(hostCalls, 2); - assert.deepStrictEqual( - refreshed.entries.map((entry) => entry.number), - [2], - ); - }), -); - it.effect("a listing narrowed to some projects is its own cache entry", () => Effect.gen(function* () { const asked: ReadonlyArray[] = []; @@ -4262,85 +2156,6 @@ it.effect( }), ); -it.effect("keeps unrelated PRs warm after a mutation, explicit refresh, and project turn", () => - Effect.gen(function* () { - const calls: string[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: (input) => - Effect.sync(() => { - calls.push(`${input.repository}/${input.number}`); - return { ...hostedChangeRequest("body"), number: input.number }; - }), - }), - ], - }); - const refs = [ - { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }, - { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }, - { projectId: "p2" as ProjectId, repository: "acme/docs", number: 3 }, - ]; - const readAll = Effect.forEach(refs, (ref) => service.summary({ ...ref, allowStale: false })); - yield* readAll; - yield* service.invalidate({ reference: { ...refs[0]!, host: "github.com" } }); - yield* readAll; - assert.deepStrictEqual(calls, ["acme/web/1", "acme/web/2", "acme/docs/3", "acme/web/1"]); - yield* service.comment({ ...refs[0]!, body: "hello" }); - yield* readAll; - assert.deepStrictEqual(calls.slice(4), ["acme/web/1"]); - yield* service.refreshAfterTurn("p1" as ProjectId); - yield* readAll; - assert.deepStrictEqual(calls.slice(5), ["acme/web/1", "acme/web/2"]); - }), -); - -it.effect( - "keeps matching PR numbers on different hosts separate and refreshes the serving project", - () => - Effect.gen(function* () { - const hosts: string[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "public", workspaceRoot: "/a", repository: "acme/web" }), - project({ - id: "p2", - title: "enterprise", - workspaceRoot: "/b", - repository: "acme/web", - host: "enterprise.test", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: (input) => - Effect.sync(() => { - hosts.push(input.host); - return hostedChangeRequest("body"); - }), - }), - ], - }); - const own = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const other = { ...own, host: "enterprise.test" }; - const readBoth = Effect.all([service.summary(own), service.summary(other)]); - yield* readBoth; - yield* service.invalidate({ reference: { ...own, host: "github.com" } }); - yield* readBoth; - assert.deepStrictEqual(hosts, ["github.com", "enterprise.test", "github.com"]); - yield* service.invalidate({ reference: own }); - yield* readBoth; - assert.deepStrictEqual(hosts.slice(3), ["github.com"]); - yield* service.refreshAfterTurn("p2" as ProjectId); - yield* readBoth; - assert.deepStrictEqual(hosts.slice(4), ["enterprise.test"]); - }), -); - it.effect("does not revive old summaries when project epochs are evicted", () => Effect.gen(function* () { let title = "old"; @@ -4353,51 +2168,13 @@ it.effect("does not revive old summaries when project epochs are evicted", () => ], }); const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - assert.strictEqual((yield* service.summary(ref))?.title, "old"); - title = "new"; - yield* service.refreshAfterTurn(ref.projectId); - assert.strictEqual((yield* service.summary(ref))?.title, "new"); - for (let index = 0; index < 2048; index++) - yield* service.refreshAfterTurn(`project-${index}` as ProjectId); - assert.strictEqual((yield* service.summary(ref))?.title, "new"); - }), -); - -it.effect("explicit and turn invalidations make the next listing ask the host again", () => - Effect.gen(function* () { - let hostCalls = 0; - let viewerCalls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: () => { - viewerCalls += 1; - return Effect.succeed("bilal"); - }, - listChangeRequests: () => { - hostCalls += 1; - return Effect.succeed({ items: [], truncated: false, continues: false }); - }, - }), - ], - }); - - yield* service.list({ state: "open" }); - yield* service.invalidate({}); - yield* service.list({ state: "open" }); - assert.strictEqual(hostCalls, 2); - assert.strictEqual(viewerCalls, 2); - - yield* service.invalidate({ reference }); - yield* service.list({ state: "open" }); - assert.strictEqual(hostCalls, 2); - yield* service.refreshAfterTurn("p1" as ProjectId); - const refresh = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)); - yield* service.list({ state: "open" }); - assert.isAbove(refresh, 0); - assert.strictEqual(hostCalls, 3); + assert.strictEqual((yield* service.summary(ref))?.title, "old"); + title = "new"; + yield* service.refreshAfterTurn(ref.projectId); + assert.strictEqual((yield* service.summary(ref))?.title, "new"); + for (let index = 0; index < 2048; index++) + yield* service.refreshAfterTurn(`project-${index}` as ProjectId); + assert.strictEqual((yield* service.summary(ref))?.title, "new"); }), ); @@ -4454,77 +2231,6 @@ it.effect("close and reopen notify subscribed readers after invalidating their c ), ); -it.effect("explicit invalidation refreshes origin readers after a routed host mutation", () => - Effect.scoped( - Effect.gen(function* () { - let state: "open" | "closed" = "open"; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: () => - Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }), - }), - ], - }); - yield* service.refreshAfterTurn(reference.projectId); - let revision = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)); - yield* service.invalidate({ reference }); - assert.strictEqual( - Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)), - revision, - ); - assert.strictEqual((yield* service.summary(reference)).state, "open"); - - for (const nextState of ["closed", "open"] as const) { - const refreshed = yield* service.subscribeRefreshes.pipe( - Stream.drop(1), - Stream.take(1), - Stream.mapEffect((nextRevision) => - service - .summary(reference) - .pipe(Effect.map((summary) => ({ revision: nextRevision, state: summary.state }))), - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - state = nextState; - yield* service.invalidate({ reference }, { notifyReaders: true }); - const result = Option.getOrThrow(yield* Fiber.join(refreshed)); - assert.strictEqual(result.state, nextState); - assert.isAbove(result.revision, revision); - revision = result.revision; - } - }), - ), -); - -it.effect("does not cache a failed listing", () => - Effect.gen(function* () { - let hostCalls = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: () => { - hostCalls += 1; - return hostCalls === 1 ? Effect.fail(requestFailed) : Effect.succeed("bilal"); - }, - }), - ], - }); - - const error = yield* Effect.flip(service.list({ state: "open" })); - assert.strictEqual(error._tag, "PullRequestOperationError"); - const second = yield* service.list({ state: "open" }); - assert.strictEqual(hostCalls, 2); - assert.strictEqual(second.providers[0]?.configured, true); - }), -); - it.effect("reads a host's repositories in one search, and files the rows back under each", () => Effect.gen(function* () { const asked: Array> = []; @@ -4649,7 +2355,7 @@ it.effect("carries a slice on without sending the rows it already sent", () => [4], ); assert.deepStrictEqual(result.nextCursors, { - "github.com acme/web": "2026-07-02T00:00:00Z|2|3,3,4", + "github.com acme/web": "2026-07-02T00:00:00Z|2|3,4", }); }), ); @@ -4727,36 +2433,6 @@ it.effect("asks on its own for a repository a search answered nothing for", () = ); }), ); -it.effect("reads the repositories one at a time when the search itself fails", () => - Effect.gen(function* () { - const separately: string[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: ({ repository }) => { - separately.push(repository); - return Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: false, - continues: true, - }); - }, - listChangeRequestsAcross: () => Effect.fail(requestFailed), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual(separately.toSorted(), ["acme/docs", "acme/web"]); - assert.deepStrictEqual(result.errors, []); - assert.strictEqual(result.entries.length, 2); - }), -); it.effect("fills in the line counts for the rows it is given", () => Effect.gen(function* () { const asked: Array = []; @@ -4810,58 +2486,6 @@ it.effect("fills in the line counts for the rows it is given", () => ]); }), ); -it.effect( - "reuses counts across overlapping pages until expiry, explicit invalidation, or a reference changes", - () => - Effect.gen(function* () { - const asked: number[][] = []; - const ref = (number: number) => ({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number, - }); - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequestStats: (input) => { - asked.push(input.changeRequests.map((ref) => ref.number)); - return Effect.succeed( - input.changeRequests.map((ref) => ({ ...ref, additions: 12, deletions: 3 })), - ); - }, - }), - ], - }); - - yield* service.listStats({ refs: [ref(1), ref(2)] }); - const overlapping = yield* service.listStats({ refs: [ref(2), ref(3)] }); - assert.deepStrictEqual( - overlapping.stats.map((stat) => stat.number), - [2, 3], - ); - yield* service.listStats({ refs: [ref(1), ref(2), ref(3)] }); - assert.deepStrictEqual(asked, [[1, 2], [3]]); - - yield* service.invalidate({ reference: ref(2) }); - yield* service.listStats({ refs: [ref(1), ref(2), ref(3)] }); - assert.deepStrictEqual(asked, [[1, 2], [3], [2]]); - - yield* TestClock.adjust("61 seconds"); - yield* service.listStats({ refs: [ref(1), ref(2), ref(3)] }); - assert.deepStrictEqual(asked, [[1, 2], [3], [2], [1, 2, 3]]); - - yield* service.refreshAfterTurn("p1" as ProjectId); - yield* service.listStats({ refs: [ref(1)] }); - assert.deepStrictEqual(asked, [[1, 2], [3], [2], [1, 2, 3], [1]]); - - yield* service.invalidate({}); - yield* service.listStats({ refs: [ref(1)] }); - assert.deepStrictEqual(asked, [[1, 2], [3], [2], [1, 2, 3], [1], [1]]); - }), -); it.effect("reads the fresh diff when detail or summary discovers a changed revision", () => Effect.gen(function* () { @@ -4918,164 +2542,6 @@ it.effect("reads the fresh diff when detail or summary discovers a changed revis }), ); -it.effect("keeps the rows when the line counts cannot be read", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequestStats: () => Effect.fail(requestFailed), - }), - ], - }); - - const result = yield* service.listStats({ - refs: [{ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }], - }); - - assert.deepStrictEqual(result.stats, []); - }), -); - -it.effect( - "serves core detail without waiting for activity, and shares activity between clients", - () => - Effect.gen(function* () { - let coreCalls = 0; - let activityCalls = 0; - let statsCalls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequestStats: () => { - statsCalls += 1; - return Effect.succeed([]); - }, - getChangeRequest: () => { - coreCalls += 1; - return Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - body: "Ready before the conversation", - changedFiles: 2, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: { - actions: ["merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }, - }); - }, - getChangeRequestActivity: () => { - activityCalls += 1; - return Effect.succeed({ - comments: [], - commentCount: 0, - commentsTruncated: true, - reviewThreadsTruncated: true, - reviewThreads: [], - commits: [], - }); - }, - }), - ], - }); - - const core = yield* service.detail(reference); - assert.strictEqual(core.body, "Ready before the conversation"); - assert.strictEqual(coreCalls, 1); - assert.strictEqual(activityCalls, 0); - - const counts = yield* service.listStats({ refs: [reference] }); - assert.strictEqual(statsCalls, 0); - assert.deepStrictEqual(counts.stats, [ - { - ...reference, - additions: core.additions, - deletions: core.deletions, - }, - ]); - - const activities = yield* Effect.all( - [service.activity(reference), service.activity(reference)], - { - concurrency: 2, - }, - ); - assert.strictEqual(activityCalls, 1); - assert.isTrue(activities.every((activity) => activity.reviewThreadsTruncated === true)); - - yield* service.invalidate({ reference }); - yield* service.activity(reference); - assert.strictEqual(activityCalls, 2); - }), -); - -it.effect("shares linked summaries and reuses them for display without asking the host again", () => - Effect.gen(function* () { - let calls = 0; - let failing = false; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: () => - Effect.sync(() => { - calls += 1; - return failing; - }).pipe( - Effect.tap(() => Effect.yieldNow), - Effect.flatMap((shouldFail) => - shouldFail - ? Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequestSummary", - reason: "failed", - detail: "HTTP 504", - }), - ) - : Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), - ), - ), - }), - ], - }); - - yield* Effect.all( - [ - service.summary(reference, { recoverTransientFailure: false }), - service.summary(reference, { recoverTransientFailure: false }), - ], - { concurrency: "unbounded" }, - ); - assert.strictEqual(calls, 1); - - yield* TestClock.adjust("61 seconds"); - failing = true; - const strict = yield* Effect.flip(service.summary({ ...reference, allowStale: false })); - assert.strictEqual(strict._tag, "PullRequestOperationError"); - - const stale = yield* service.summary(reference); - assert.strictEqual(stale.updatedAt, "2026-07-02T00:00:00Z"); - assert.strictEqual(calls, 2); - - yield* service.invalidate({ reference }); - const invalidated = yield* Effect.flip(service.summary(reference)); - assert.strictEqual(invalidated._tag, "PullRequestOperationError"); - }), -); - it.effect("keeps routed reads separate when the GitHub account changes", () => Effect.gen(function* () { for (const operation of ["summary", "detail", "checks", "diff", "filesViewed"] as const) { @@ -5224,104 +2690,46 @@ it.effect("rejects mismatched routing credentials before use and preserves actio fakeProvider(SourceControlProviderKind.make("github"), { getRoutingIdentity: () => Effect.succeed({ accountId: "101", viewer: "octocat" }), withVerifiedCredential: (_, use) => - use({ - accountId: "101", - viewer: "octocat", - credentialFingerprint: "credential-a", - }), - }), - ], - }); - const reference = { - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - host: "github.com", - expectedAccountId: "202", - }; - const actionError = new PullRequestOperationError({ - operation: "runAction", - detail: "ambiguous", - }); - const operation = Effect.sync(() => { - operations += 1; - }).pipe(Effect.andThen(Effect.fail(actionError))); - const rejected = yield* Effect.flip(service.withRoutingCredential(reference, operation)); - assert.strictEqual(rejected._tag, "PullRequestOperationError"); - if (rejected._tag === "PullRequestOperationError") - assert.strictEqual(rejected.operation, "routeIdentity"); - assert.strictEqual(operations, 0); - assert.strictEqual( - yield* Effect.flip( - service.withRoutingCredential({ ...reference, expectedAccountId: "101" }, operation), - ), - actionError, - ); - assert.strictEqual(operations, 1); - assert.deepStrictEqual(yield* service.routingIdentity({ host: "github.com" }), { - accountId: "101", - viewer: "octocat", - host: "github.com", - provider: "github", - }); - }), -); - -it.effect("answers a known pull request immediately while the host refreshes", () => - Effect.gen(function* () { - const gate = yield* Deferred.make(); - let calls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => - Effect.gen(function* () { - calls += 1; - if (calls > 1) yield* Deferred.await(gate); - return hostedChangeRequest("cached body", 4); - }), - }), - ], - }); - - const first = yield* service.detail(reference); - assert.strictEqual(first.body, "cached body"); - assert.strictEqual(first.additions, 4); - - yield* TestClock.adjust("61 seconds"); - const second = yield* service.detail(reference); - assert.strictEqual(second.body, "cached body"); - assert.strictEqual(second.additions, 4); - yield* Effect.yieldNow; - assert.strictEqual(calls, 2); - }), -); - -it.effect("does not ask the host again for a linked summary it already holds", () => - Effect.gen(function* () { - let calls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: () => - Effect.sync(() => { - calls += 1; - return changeRequest(1, "2026-07-02T00:00:00Z"); + use({ + accountId: "101", + viewer: "octocat", + credentialFingerprint: "credential-a", }), }), ], }); - - const first = yield* service.summary(reference); - assert.strictEqual(first.title, "Change request 1"); - yield* TestClock.adjust("61 seconds"); - const second = yield* service.summary(reference); - assert.strictEqual(second.title, "Change request 1"); - assert.strictEqual(calls, 1); + const reference = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + host: "github.com", + expectedAccountId: "202", + }; + const actionError = new PullRequestOperationError({ + operation: "runAction", + detail: "ambiguous", + }); + const operation = Effect.sync(() => { + operations += 1; + }).pipe(Effect.andThen(Effect.fail(actionError))); + const rejected = yield* Effect.flip(service.withRoutingCredential(reference, operation)); + assert.strictEqual(rejected._tag, "PullRequestOperationError"); + if (rejected._tag === "PullRequestOperationError") + assert.strictEqual(rejected.operation, "routeIdentity"); + assert.strictEqual(operations, 0); + assert.strictEqual( + yield* Effect.flip( + service.withRoutingCredential({ ...reference, expectedAccountId: "101" }, operation), + ), + actionError, + ); + assert.strictEqual(operations, 1); + assert.deepStrictEqual(yield* service.routingIdentity({ host: "github.com" }), { + accountId: "101", + viewer: "octocat", + host: "github.com", + provider: "github", + }); }), ); @@ -5367,124 +2775,97 @@ it.effect( }), ); -it.effect("reuses an observed merged state for strict settlement reads", () => +it.effect("notifies linked threads when detail changes draft status in either direction", () => Effect.gen(function* () { + let isDraft = true; + let updatedAt = "2026-07-02T00:00:00Z"; const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => - Effect.succeed({ - ...hostedChangeRequest("merged body", 4), - state: "merged", - updatedAt: "2026-07-03T00:00:00Z", - }), - getChangeRequestSummary: () => Effect.die("strict merged state must not refresh"), + Effect.sync(() => ({ ...hostedChangeRequest("body"), isDraft, updatedAt })), }), ], }); + const changes = yield* service.subscribeStateChanges; + const read = () => service.detail({ ...reference, allowStale: false }); + yield* read(); + yield* read(); + isDraft = false; + yield* service.invalidate({ reference }); + yield* read(); + yield* read(); + isDraft = true; + yield* service.invalidate({ reference }); + yield* read(); - yield* service.detail(reference); + isDraft = false; + updatedAt = "2026-07-01T00:00:00Z"; + yield* service.invalidate({ reference }); + yield* read(); - const summary = yield* service.summary(reference, { recoverTransientFailure: false }); - assert.strictEqual(summary.state, "merged"); - assert.strictEqual(summary.updatedAt, "2026-07-03T00:00:00Z"); + // A different PR ends collection after all the synchronous notifications above. + yield* service.reportState({ reference: { ...reference, number: 2 }, state: "open" }); + const events = yield* Stream.runCollect( + changes.pipe(Stream.takeUntil((event) => event.number === 2, { excludeLast: true })), + ); + assert.deepStrictEqual(events, [ + { host: "github.com", repository: "acme/web", number: 1 }, + { host: "github.com", repository: "acme/web", number: 1 }, + { host: "github.com", repository: "acme/web", number: 1 }, + ]); }), ); -it.effect("announces state a detail read sees first or newly", () => +it.effect("deduplicates routed PR reports without dropping draft changes", () => Effect.gen(function* () { - let detail = { - state: "open" as "open" | "closed" | "merged", - updatedAt: "2026-07-02T00:00:00Z", - }; - let summaryState: "open" | "merged" = "open"; const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }), - getChangeRequestSummary: () => - Effect.succeed({ - ...changeRequest(1, "2026-07-04T00:00:00Z"), - state: summaryState, - }), - }), - ], - }); - const announced: Array = []; - yield* Stream.runForEach(yield* service.subscribeStateChanges, (key) => - Effect.sync(() => announced.push(`${key.host}/${key.repository}#${key.number}`)), - ).pipe(Effect.forkChild({ startImmediately: true })); - const readDetail = Effect.gen(function* () { - yield* service.invalidate({ reference }); - yield* service.detail(reference); - yield* Effect.yieldNow; + providers: [fakeProvider(SourceControlProviderKind.make("github"))], }); - - yield* readDetail; - yield* readDetail; - assert.deepStrictEqual(announced, ["github.com/acme/web#1"]); - - detail = { state: "closed", updatedAt: "2026-07-03T00:00:00Z" }; - yield* readDetail; - assert.strictEqual(announced.length, 2); - - summaryState = "merged"; - yield* service.summary(reference, { recoverTransientFailure: false }); - yield* Effect.yieldNow; - assert.strictEqual(announced.length, 2); - detail = { state: "merged", updatedAt: "2026-07-04T00:00:00Z" }; - yield* readDetail; - assert.strictEqual(announced.length, 3); - - detail = { state: "open", updatedAt: "2026-07-03T00:00:00Z" }; - yield* readDetail; - assert.strictEqual(announced.length, 3); + const changes = yield* service.subscribeStateChanges; + for (const isDraft of [true, true, false, false, true]) { + yield* service.reportState({ reference, state: "open", isDraft }); + } + yield* service.reportState({ reference, state: "open" }); + yield* service.reportState({ reference, state: "open" }); + yield* service.reportState({ reference: { ...reference, number: 2 }, state: "open" }); + const events = yield* Stream.runCollect( + changes.pipe(Stream.takeUntil((event) => event.number === 2, { excludeLast: true })), + ); + assert.deepStrictEqual( + events.map((event) => event.number), + [1, 1, 1, 1], + ); }), ); -it.effect("announces a reported state without trusting it", () => +it.effect("reuses an observed merged state for strict settlement reads", () => Effect.gen(function* () { - let detail = { state: "open" as "open" | "closed", updatedAt: "2026-07-02T00:00:00Z" }; const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }), + getChangeRequest: () => + Effect.succeed({ + ...hostedChangeRequest("merged body", 4), + state: "merged", + updatedAt: "2026-07-03T00:00:00Z", + }), + getChangeRequestSummary: () => Effect.die("strict merged state must not refresh"), }), ], }); - const announced: Array = []; - yield* Stream.runForEach(yield* service.subscribeStateChanges, (key) => - Effect.sync(() => announced.push(`${key.host}/${key.repository}#${key.number}`)), - ).pipe(Effect.forkChild({ startImmediately: true })); - const note = (state: "open" | "closed" | "merged") => - service.reportState({ reference, state }).pipe(Effect.andThen(Effect.yieldNow)); - const readDetail = Effect.gen(function* () { - yield* service.invalidate({ reference }); - yield* service.detail(reference); - yield* Effect.yieldNow; - }); - yield* readDetail; - yield* note("closed"); - yield* note("closed"); - assert.strictEqual(announced.length, 2); - yield* TestClock.adjust("5 minutes"); - yield* note("closed"); - assert.strictEqual(announced.length, 3); - - yield* note("open"); - assert.strictEqual(announced.length, 4); - - yield* note("merged"); - detail = { state: "closed", updatedAt: "2026-07-03T00:00:00Z" }; - yield* readDetail; - assert.strictEqual(announced.length, 6); + yield* service.detail(reference); + + const summary = yield* service.summary(reference, { recoverTransientFailure: false }); + assert.strictEqual(summary.state, "merged"); + assert.strictEqual(summary.updatedAt, "2026-07-03T00:00:00Z"); }), ); @@ -5544,252 +2925,12 @@ it.effect("does not let a stale detail reopen overwrite a fresher linked summary }), ); -it.effect("does not let a still-cached detail overwrite a fresher linked summary", () => - Effect.gen(function* () { - let summaryTitle = "old title"; - let summaryState: "open" | "merged" = "open"; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => Effect.succeed(hostedChangeRequest("old body", 4)), - getChangeRequestSummary: () => - Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - title: summaryTitle, - state: summaryState, - }), - }), - ], - }); - - const first = yield* service.detail(reference); - assert.strictEqual(first.title, "Change request 1"); - - summaryTitle = "merged title"; - summaryState = "merged"; - const settled = yield* service.summary(reference, { recoverTransientFailure: false }); - assert.strictEqual(settled.state, "merged"); - - const cached = yield* service.detail(reference); - assert.strictEqual(cached.title, "Change request 1"); - yield* Effect.yieldNow; - - const display = yield* service.summary(reference); - assert.strictEqual(display.title, "merged title"); - assert.strictEqual(display.state, "merged"); - }), -); - -it.effect("tells the client when the host has no pull request under that number", () => - Effect.gen(function* () { - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 121 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequest", - reason: "not-found", - detail: "Pull request not found. Check the PR number or URL and try again.", - }), - ), - }), - ], - }); - - const error = yield* Effect.flip(service.detail(reference)); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.strictEqual(error._tag === "PullRequestOperationError" && error.reason, "not-found"); - }), -); - -it.effect("keeps recent detail on a transient refresh failure but not after invalidation", () => - Effect.gen(function* () { - let failing = false; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => - failing - ? Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequest", - reason: "failed", - detail: "spawn gh EAGAIN", - }), - ) - : Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - body: "last good body", - changedFiles: 2, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: { - actions: ["merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }, - }), - }), - ], - }); - - yield* service.detail(reference); - yield* TestClock.adjust("61 seconds"); - failing = true; - const strict = yield* Effect.flip(service.detail({ ...reference, allowStale: false })); - assert.strictEqual(strict._tag, "PullRequestOperationError"); - const stale = yield* service.detail(reference); - assert.strictEqual(stale.body, "last good body"); - - yield* service.invalidate({ reference }); - const invalidated = yield* Effect.flip(service.detail(reference)); - assert.strictEqual(invalidated._tag, "PullRequestOperationError"); - }), -); - -it.effect("carries an armed auto-merge through to the detail, and silence as silence", () => - Effect.gen(function* () { - const detailWith = (autoMergeEnabled: boolean | undefined) => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => - Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - body: "", - changedFiles: 0, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: { - actions: ["merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }, - ...(autoMergeEnabled === undefined ? {} : { autoMergeEnabled }), - }), - }), - ], - }); - return yield* service.detail({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - }); - }); - - assert.strictEqual((yield* detailWith(true)).autoMergeEnabled, true); - assert.strictEqual((yield* detailWith(false)).autoMergeEnabled, false); - assert.isUndefined((yield* detailWith(undefined)).autoMergeEnabled); - }), -); - -it.effect("narrows the rows of a host that ignored the filters it was handed", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "web", - workspaceRoot: "/a", - repository: "acme/web", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - listChangeRequests: () => - Effect.succeed({ - items: [ - { ...changeRequest(1, "2026-07-02T00:00:00Z"), isDraft: true }, - changeRequest(2, "2026-07-01T00:00:00Z"), - ], - truncated: false, - continues: false, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open", filters: { draft: "hide" } }); - - assert.deepStrictEqual( - result.entries.map((entry) => entry.number), - [2], - ); - }), -); - it.effect("keeps a row of a host that ignored the filters if any name of a label group holds", () => Effect.gen(function* () { const sized = (number: number, updatedAt: string, ...names: ReadonlyArray) => ({ ...changeRequest(number, updatedAt), - labels: names.map((name) => ({ name, color: null })), - }); - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "web", - workspaceRoot: "/a", - repository: "acme/web", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - listChangeRequests: () => - Effect.succeed({ - items: [ - sized(1, "2026-07-04T00:00:00Z", "size:S", "bug"), - sized(2, "2026-07-03T00:00:00Z", "size:XS", "bug"), - sized(3, "2026-07-02T00:00:00Z", "size:L", "bug"), - sized(4, "2026-07-01T00:00:00Z", "size:S"), - ], - truncated: false, - continues: false, - }), - }), - ], - }); - - const result = yield* service.list({ - state: "open", - filters: { labels: [["size:S", "size:XS"], ["bug"]] }, - }); - - assert.deepStrictEqual( - result.entries.map((entry) => entry.number), - [1, 2], - ); - }), -); - -it.effect('resolves an author filter of "me" to the viewer before narrowing a host\'s rows', () => - Effect.gen(function* () { + labels: names.map((name) => ({ name, color: null })), + }); const service = yield* makeService({ projects: [ project({ @@ -5805,11 +2946,10 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho listChangeRequests: () => Effect.succeed({ items: [ - changeRequest(1, "2026-07-02T00:00:00Z"), - { - ...changeRequest(2, "2026-07-01T00:00:00Z"), - author: { login: "bilal", name: null, avatarUrl: null }, - }, + sized(1, "2026-07-04T00:00:00Z", "size:S", "bug"), + sized(2, "2026-07-03T00:00:00Z", "size:XS", "bug"), + sized(3, "2026-07-02T00:00:00Z", "size:L", "bug"), + sized(4, "2026-07-01T00:00:00Z", "size:S"), ], truncated: false, continues: false, @@ -5818,11 +2958,14 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho ], }); - const result = yield* service.list({ state: "open", filters: { author: "me" } }); + const result = yield* service.list({ + state: "open", + filters: { labels: [["size:S", "size:XS"], ["bug"]] }, + }); assert.deepStrictEqual( result.entries.map((entry) => entry.number), - [2], + [1, 2], ); }), ); @@ -5971,61 +3114,6 @@ it.effect("refuses a way of updating a branch that the host or the viewer does n }), ); -it.effect("refuses to merge a target branch into a source branch on a host that only rebases", () => - Effect.gen(function* () { - let taken = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge", "close", "update-branch"], - mergeMethods: ["merge"], - updateMethods: ["rebase"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getViewerPermissions: () => - Effect.succeed({ - actions: ["close", "update-branch"], - comment: true, - resolve: true, - verdicts: ["comment"], - requestReviewers: false, - updateMethods: ["rebase"], - }), - runAction: () => { - taken += 1; - return Effect.void; - }, - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "group/project", number: 1 }; - - const error = yield* Effect.flip( - service.runAction({ ...reference, action: "update-branch", updateMethod: "merge" }), - ); - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.strictEqual(taken, 0); - - yield* service.runAction({ ...reference, action: "update-branch", updateMethod: "rebase" }); - assert.strictEqual(taken, 1); - }), -); - it.effect("judges the review filter only on a host that summarises its reviews", () => Effect.gen(function* () { const service = yield* makeService({ @@ -6058,414 +3146,77 @@ it.effect("judges the review filter only on a host that summarises its reviews", listChangeRequests: () => Effect.succeed({ items: [changeRequest(3, "2026-07-02T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - ], - }); - - const none = yield* service.list({ state: "open", filters: { review: "none" } }); - assert.deepStrictEqual(none.entries.map((entry) => entry.number).toSorted(), [1, 3]); - - const approved = yield* service.list({ state: "open", filters: { review: "approved" } }); - assert.deepStrictEqual(approved.entries.map((entry) => entry.number).toSorted(), [2, 3]); - }), -); - -it.effect("sends only the words a rewrite carries", () => - Effect.gen(function* () { - const received: Array<{ title?: string | undefined; body?: string | undefined }> = []; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - updateChangeRequest: (input) => { - received.push({ title: input.title, body: input.body }); - return Effect.void; - }, - }), - ], - }); - - yield* service.update({ ...reference, title: "A better title" }); - yield* service.update({ ...reference, body: "" }); - yield* service.update({ ...reference, title: "Both", body: "at once" }); - - assert.deepStrictEqual(received, [ - { title: "A better title", body: undefined }, - { title: undefined, body: "" }, - { title: "Both", body: "at once" }, - ]); - }), -); - -it.effect("refuses a rewrite that changes nothing, before any call is made", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - updateChangeRequest: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.update({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "Nothing was changed."); - }), -); - -it.effect("refuses to rewrite anything on a host that never claimed it", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - updateChangeRequest: () => Effect.die("must not be called"), - updateComment: () => Effect.die("must not be called"), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - - const rewriteRefused = yield* Effect.flip(service.update({ ...reference, title: "New" })); - const commentRefused = yield* Effect.flip( - service.updateComment({ - ...reference, - commentId: "IC_1", - kind: "issue-comment", - body: "New", - }), - ); - - assert.include(rewriteRefused.message, "cannot rewrite a change request."); - assert.include(commentRefused.message, "cannot rewrite a comment."); - }), -); - -it.effect("passes a rewritten remark through with the id and kind it arrived under", () => - Effect.gen(function* () { - let received: { id: string; kind: string; body: string } | null = null; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - updateComment: (input) => { - received = { id: input.commentId, kind: input.kind, body: input.body }; - return Effect.void; - }, - }), - ], - }); - - yield* service.updateComment({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - commentId: "PRRC_1", - kind: "review-comment", - body: "Second thoughts", - }); - - assert.deepStrictEqual(received, { - id: "PRRC_1", - kind: "review-comment", - body: "Second thoughts", - }); - }), -); - -it.effect("refuses a remark rewritten into nothing but whitespace", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - updateComment: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.updateComment({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - commentId: "IC_1", - kind: "issue-comment", - body: " \n ", - }), - ); - - assert.include(error.message, "A comment cannot be empty."); - }), -); - -it.effect("forgets the cached detail after a rewrite or terminal turn", () => - Effect.gen(function* () { - let coreCalls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => { - coreCalls += 1; - return Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - body: "", - changedFiles: 0, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: { - actions: ["merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }, - }); - }, - }), - ], - }); - - yield* service.detail(reference); - yield* service.update({ ...reference, title: "Renamed" }); - yield* service.detail(reference); - assert.strictEqual(coreCalls, 2); - - yield* service.refreshAfterTurn("p1" as ProjectId); - yield* service.detail(reference); - assert.strictEqual(coreCalls, 3); - }), -); - -it.effect("names the signed-in account in the detail, and says nothing where the host cannot", () => - Effect.gen(function* () { - const detailFrom = (provider: PullRequestProviderApi) => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [provider], - }); - return yield* service.detail({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - }); - }); - const readable = fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => - Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - body: "", - changedFiles: 0, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: { - actions: ["merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }, - }), - }); - - const named = yield* detailFrom(readable); - const unnamed = yield* detailFrom({ - ...readable, - getViewer: () => - Effect.fail(unusable(SourceControlProviderKind.make("github"), "unauthenticated")), - }); - - assert.strictEqual(named.viewer, "bilal"); - assert.strictEqual(unnamed.viewer, undefined); - }), -); - -it.effect("keeps the diff cached across a file being ticked off", () => - Effect.gen(function* () { - let diffReads = 0; - let viewedReads = 0; - let state: "viewed" | "dismissed" = "viewed"; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - viewedFiles: "host", - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getDiff: () => { - diffReads += 1; - return Effect.succeed({ patch: "@@", truncated: false, nextCursor: null }); - }, - getFilesViewed: () => { - viewedReads += 1; - return Effect.succeed({ - files: [{ path: "src/a.ts", state }], - truncated: false, - }); - }, - setFilesViewed: () => Effect.void, - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; - - yield* service.diff(reference); - yield* service.filesViewed(reference); - yield* service.setFilesViewed({ ...reference, files: [{ path: "src/a.ts", viewed: false }] }); - yield* service.diff(reference); - yield* service.filesViewed(reference); - - assert.strictEqual(diffReads, 1); - assert.strictEqual(viewedReads, 2); - - state = "dismissed"; - yield* service.invalidate({ reference, filesViewedOnly: true }); - yield* service.diff(reference); - assert.deepStrictEqual((yield* service.filesViewed(reference)).files, [ - { path: "src/a.ts", state: "dismissed" }, - ]); - assert.strictEqual(diffReads, 1); - assert.strictEqual(viewedReads, 3); - }), -); - -it.effect("returns large diff slices intact without retaining them in either cache", () => - Effect.gen(function* () { - let reads = 0; - const patch = "\u{1f4bb}".repeat(140_000); - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getDiff: () => - Effect.sync(() => { - reads += 1; - return { patch, truncated: false, nextCursor: "2" }; + truncated: false, + continues: true, }), }), ], }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - for (const input of [ - reference, - { ...reference, cursor: "2" }, - { ...reference, commit: "a".repeat(40) }, - ]) { - const before = reads; - assert.deepStrictEqual(yield* service.diff(input), { - patch, - truncated: false, - nextCursor: "2", - }); - assert.deepStrictEqual(yield* service.diff(input), { - patch, - truncated: false, - nextCursor: "2", - }); - assert.strictEqual(reads, before + 2); - } + + const none = yield* service.list({ state: "open", filters: { review: "none" } }); + assert.deepStrictEqual(none.entries.map((entry) => entry.number).toSorted(), [1, 3]); + + const approved = yield* service.list({ state: "open", filters: { review: "approved" } }); + assert.deepStrictEqual(approved.entries.map((entry) => entry.number).toSorted(), [2, 3]); }), ); -it.effect("caches a small replacement after releasing a large diff", () => +it.effect("partial host viewed writes invalidate marks but keep the diff cached", () => Effect.gen(function* () { - let reads = 0; - const largePatch = "x".repeat(300_000); + let diffReads = 0; + const states = new Map([ + ["a.ts", "unviewed"], + ["b.ts", "unviewed"], + ]); const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { + capabilities: { + ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, + viewedFiles: "host", + }, getDiff: () => Effect.sync(() => { - reads += 1; - return { - patch: reads === 1 ? largePatch : "@@ small replacement", - truncated: false, - nextCursor: null, - }; + diffReads += 1; + return { patch: "@@", truncated: false, nextCursor: null }; + }), + getFilesViewed: () => + Effect.sync(() => ({ + files: [...states].map(([path, state]) => ({ path, state })), + truncated: false, + })), + setFilesViewed: () => + Effect.gen(function* () { + states.set("a.ts", "viewed"); + return yield* new PullRequestProviderError({ + provider: SourceControlProviderKind.make("github"), + operation: "setFilesViewed", + reason: "failed", + detail: "Second mutation refused", + }); }), }), ], }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - assert.strictEqual((yield* service.diff(reference)).patch, largePatch); - assert.strictEqual((yield* service.diff(reference)).patch, "@@ small replacement"); - assert.strictEqual((yield* service.diff(reference)).patch, "@@ small replacement"); - assert.strictEqual(reads, 2); - }), -); - -it.effect("releases an expired diff nobody reads again", () => - Effect.gen(function* () { - NodeV8.setFlagsFromString("--expose-gc"); - const collect = NodeVM.runInNewContext("gc") as () => void; - const settle = Effect.promise(async () => { - // A weak reference keeps its target for the rest of the job that created it. - await new Promise((resolve) => setImmediate(resolve)); - collect(); - }); - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getDiff: () => Effect.sync(() => ({ patch: "@@", truncated: false, nextCursor: null })), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const read = (input: Parameters[0]) => - Effect.map(service.diff(input), (result) => new WeakRef(result)); - const page = yield* read(reference); - const commit = yield* read({ ...reference, commit: "a".repeat(40) }); - - yield* TestClock.adjust("9 minutes"); - yield* settle; - assert.isDefined(page.deref()); - assert.isDefined(commit.deref()); - - yield* TestClock.adjust("2 minutes"); - yield* settle; - assert.isUndefined(page.deref()); - assert.isUndefined(commit.deref()); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 10 }; + yield* service.diff(ref); + assert.deepStrictEqual((yield* service.filesViewed(ref)).files, [ + { path: "a.ts", state: "unviewed" }, + { path: "b.ts", state: "unviewed" }, + ]); + yield* Effect.flip( + service.setFilesViewed({ + ...ref, + files: [...states.keys()].map((path) => ({ path, viewed: true })), + }), + ); + yield* service.diff(ref); + assert.strictEqual(diffReads, 1); + assert.deepStrictEqual((yield* service.filesViewed(ref)).files, [ + { path: "a.ts", state: "viewed" }, + { path: "b.ts", state: "unviewed" }, + ]); }), ); @@ -6753,176 +3504,6 @@ it.effect("keeps viewed files itself for a host that keeps none of its own", () }), ); -it.effect("holds what a whole-change answer carried, so the next tick reads nothing", () => - Effect.gen(function* () { - const asked: Array> = []; - const head = new Map([ - ["src/a.ts", "blob-a"], - ["src/b.ts", "blob-b"], - ]); - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(head, []), - getFileRevisions: (input) => { - asked.push(input.paths); - return Effect.succeed({ revisions: head, complete: true }); - }, - }, - ], - }); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }); - - assert.deepStrictEqual(asked, [["src/a.ts"]]); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - assert.deepStrictEqual( - [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), - [ - { path: "src/a.ts", state: "viewed" }, - { path: "src/b.ts", state: "viewed" }, - ], - ); - - yield* TestClock.adjust("2 minutes"); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }); - assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]); - }), -); - -it.effect("reads the marks without asking the host what the head has every time", () => - Effect.gen(function* () { - const asked: Array> = []; - const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - yield* TestClock.adjust("20 seconds"); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual(marked.files, [{ path: "src/a.ts", state: "viewed" }]); - assert.deepStrictEqual(asked, [["src/a.ts"]]); - }), -); - -it.effect("answers the marks from what it last heard while it asks the host again", () => - Effect.gen(function* () { - const asked: Array> = []; - const revisions = new Map([["src/a.ts", "blob-a"]]); - const service = yield* environmentViewedService(revisions, asked); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - revisions.set("src/a.ts", "blob-a-again"); - yield* TestClock.adjust("90 seconds"); - const held = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual(held.files, [{ path: "src/a.ts", state: "viewed" }]); - assert.strictEqual(asked.length, 2); - - yield* TestClock.adjust("20 seconds"); - const caught = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual(caught.files, [{ path: "src/a.ts", state: "dismissed" }]); - assert.strictEqual(asked.length, 2); - }), -); - -it.effect("asks the host about a file it has not been asked about before", () => - Effect.gen(function* () { - const asked: Array> = []; - const service = yield* environmentViewedService( - new Map([ - ["src/a.ts", "blob-a"], - ["src/b.ts", "blob-b"], - ]), - asked, - ); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - yield* TestClock.adjust("20 seconds"); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }); - yield* TestClock.adjust("20 seconds"); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual( - [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), - [ - { path: "src/a.ts", state: "viewed" }, - { path: "src/b.ts", state: "viewed" }, - ], - ); - assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]); - }), -); - -it.effect("does not let a press about one file keep another file's version alive", () => - Effect.gen(function* () { - const asked: Array> = []; - const revisions = new Map([ - ["src/a.ts", "blob-a"], - ["src/b.ts", "blob-b"], - ]); - const service = yield* environmentViewedService(revisions, asked); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - yield* TestClock.adjust("40 seconds"); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }); - assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]); - - revisions.set("src/a.ts", "blob-a-again"); - yield* TestClock.adjust("30 seconds"); - yield* service.filesViewed(GITLAB_REFERENCE); - assert.strictEqual(asked.length, 3); - - yield* TestClock.adjust("20 seconds"); - const caught = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual( - [...caught.files].toSorted((left, right) => left.path.localeCompare(right.path)), - [ - { path: "src/a.ts", state: "dismissed" }, - { path: "src/b.ts", state: "viewed" }, - ], - ); - }), -); - it.effect("reports a file pushed to since it was cleared as changed", () => Effect.gen(function* () { const revisions = new Map([ @@ -6952,136 +3533,51 @@ it.effect("reports a file pushed to since it was cleared as changed", () => }), ); -it.effect("clears a mark again when the file is put back", () => - Effect.gen(function* () { - const asked: Array> = []; - const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: false }], - }); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual(marked.files, []); - assert.deepStrictEqual(asked, [["src/a.ts"]]); - }), -); - it.effect("keeps a deleted file cleared, which the head has no version of at all", () => Effect.gen(function* () { - const service = yield* environmentViewedService(new Map(), []); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/gone.ts", viewed: true }], - }); - yield* service.invalidate({ reference: GITLAB_REFERENCE }); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual(marked.files, [{ path: "src/gone.ts", state: "viewed" }]); - }), -); - -it.effect("leaves a mark alone when the host could not say what the head has of it", () => - Effect.gen(function* () { - const revisions = new Map([ - ["src/a.ts", "blob-a"], - ["src/past-the-cut.ts", "blob-b"], - ]); - const service = yield* environmentViewedService( - revisions, - [], - new Set(["src/past-the-cut.ts"]), - ); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [ - { path: "src/a.ts", viewed: true }, - { path: "src/past-the-cut.ts", viewed: true }, - ], - }); - revisions.set("src/a.ts", "blob-a-again"); - yield* service.invalidate({ reference: GITLAB_REFERENCE }); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual( - [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), - [ - { path: "src/a.ts", state: "dismissed" }, - { path: "src/past-the-cut.ts", state: "viewed" }, - ], - ); - }), -); - -it.effect("keeps a file cleared that the press could not learn a version for", () => - Effect.gen(function* () { - const revisions = new Map([["src/past-the-cut.ts", "blob-b"]]); - const unreadable = new Set(["src/past-the-cut.ts"]); - const service = yield* environmentViewedService(revisions, [], unreadable); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/past-the-cut.ts", viewed: true }], - }); - unreadable.delete("src/past-the-cut.ts"); - yield* service.invalidate({ reference: GITLAB_REFERENCE }); - - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/past-the-cut.ts", state: "viewed" }, - ]); - }), -); - -it.effect("keeps the version it last heard when a later read of the head stops short", () => - Effect.gen(function* () { - const asked: Array> = []; - const revisions = new Map([["src/a.ts", "blob-a"]]); - const unreadable = new Set(); - const service = yield* environmentViewedService(revisions, asked, unreadable); + const service = yield* environmentViewedService(new Map(), []); yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/gone.ts", viewed: true }], }); - revisions.set("src/a.ts", "blob-a-again"); yield* service.invalidate({ reference: GITLAB_REFERENCE }); - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "dismissed" }, - ]); - - unreadable.add("src/a.ts"); - yield* TestClock.adjust("90 seconds"); - yield* service.filesViewed(GITLAB_REFERENCE); - yield* TestClock.adjust("20 seconds"); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "dismissed" }, - ]); + assert.deepStrictEqual(marked.files, [{ path: "src/gone.ts", state: "viewed" }]); }), ); -it.effect("re-asks what the head has of a marked file after a whole-workspace refresh", () => +it.effect("leaves a mark alone when the host could not say what the head has of it", () => Effect.gen(function* () { - const revisions = new Map([["src/a.ts", "blob-a"]]); - const service = yield* environmentViewedService(revisions, []); + const revisions = new Map([ + ["src/a.ts", "blob-a"], + ["src/past-the-cut.ts", "blob-b"], + ]); + const service = yield* environmentViewedService( + revisions, + [], + new Set(["src/past-the-cut.ts"]), + ); yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [ + { path: "src/a.ts", viewed: true }, + { path: "src/past-the-cut.ts", viewed: true }, + ], }); revisions.set("src/a.ts", "blob-a-again"); - yield* service.invalidate({}); + yield* service.invalidate({ reference: GITLAB_REFERENCE }); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "dismissed" }, - ]); + assert.deepStrictEqual( + [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), + [ + { path: "src/a.ts", state: "dismissed" }, + { path: "src/past-the-cut.ts", state: "viewed" }, + ], + ); }), ); @@ -7103,57 +3599,6 @@ it.effect("forgets what the head had of a marked file once a mutation moves the }), ); -it.effect("still reports its own marks when the host will not say what the head has", () => - Effect.gen(function* () { - let answering = true; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - viewedFiles: "environment", - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getFilesViewed: () => Effect.die("the host keeps no marks of its own"), - setFilesViewed: () => Effect.die("the host keeps no marks of its own"), - getFileRevisions: (input) => - answering - ? Effect.succeed({ - revisions: new Map(input.paths.map((path) => [path, "blob-a"] as const)), - }) - : Effect.fail(requestFailed), - }), - ], - }); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - answering = false; - yield* service.invalidate({ reference: GITLAB_REFERENCE }); - - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "viewed" }, - ]); - }), -); - it.effect("finishes two presses on one file in the order they were made", () => Effect.gen(function* () { const held = yield* Deferred.make(); @@ -7247,470 +3692,103 @@ const AZURE_PAIR = [ host: "dev.azure.com", }), project({ - id: "p2", - title: "other web", - workspaceRoot: "/b", - repository: "acme/other/_git/web", - provider: SourceControlProviderKind.make("azure-devops"), - host: "dev.azure.com", - }), -]; - -const AZURE_PLATFORM = { projectId: "p1" as ProjectId, repository: "web", number: 1 }; -const AZURE_OTHER = { projectId: "p2" as ProjectId, repository: "web", number: 1 }; - -it.effect("keeps the marks of two Azure repositories of the same name apart", () => - Effect.gen(function* () { - const service = yield* azureViewedService(AZURE_PAIR); - - yield* service.setFilesViewed({ - ...AZURE_PLATFORM, - files: [{ path: "src/a.ts", viewed: true }], - }); - - assert.deepStrictEqual((yield* service.filesViewed(AZURE_PLATFORM)).files, [ - { path: "src/a.ts", state: "viewed" }, - ]); - assert.deepStrictEqual((yield* service.filesViewed(AZURE_OTHER)).files, []); - }), -); - -it.effect("keeps environment marks apart from another change request's", () => - Effect.gen(function* () { - const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), []); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - const other = yield* service.filesViewed({ ...GITLAB_REFERENCE, number: 2 }); - - assert.deepStrictEqual(other.files, []); - }), -); - -it.effect("bounds the paths one change request's held revisions carry", () => - Effect.gen(function* () { - const asked: Array> = []; - const service = yield* environmentViewedService(new Map(), asked); - const batch = (prefix: string, count: number) => - Array.from({ length: count }, (_, index) => ({ - path: `${prefix}/${String(index).padStart(4, "0")}.ts`, - viewed: true, - })); - const press = (prefix: string, count: number) => - service.setFilesViewed({ ...GITLAB_REFERENCE, files: batch(prefix, count) }); - - yield* press("a", MAX_FILE_REVISION_PATHS / 2); - yield* press("b", MAX_FILE_REVISION_PATHS / 2); - yield* press("c", 1); - const pressed = asked.length; - - yield* service.filesViewed(GITLAB_REFERENCE); - - assert.strictEqual(asked.length, pressed + 1); - assert.ok(asked.at(-1)?.includes("a/0000.ts")); - }), -); - -it.effect("keeps the marked paths when a whole-change answer is wider than the cap", () => - Effect.gen(function* () { - const head = new Map( - Array.from( - { length: MAX_FILE_REVISION_PATHS + 178 }, - (_, index) => - [`src/f${String(index).padStart(4, "0")}.ts`, `blob-${String(index)}`] as const, - ), - ); - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(head, []), - getFileRevisions: () => Effect.succeed({ revisions: head, complete: true }), - }, - ], - }); - const ticked = ["src/f0000.ts", "src/f0500.ts"]; - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: ticked.map((path) => ({ path, viewed: true })), - }); - for (const path of ticked) head.set(path, "blob-moved"); - yield* TestClock.adjust("11 minutes"); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual( - [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), - [ - { path: "src/f0000.ts", state: "dismissed" }, - { path: "src/f0500.ts", state: "dismissed" }, - ], - ); - }), -); - -it.effect("keeps the change request being ticked through, not the one pressed first", () => - Effect.gen(function* () { - const asked: Array> = []; - const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); - const press = (number: number) => - service.setFilesViewed({ - ...GITLAB_REFERENCE, - number, - files: [{ path: "src/a.ts", viewed: true }], - }); - - yield* press(1); - for (let filled = 0; filled < FILE_REVISIONS_CACHE_CAPACITY; filled += 1) { - yield* press(2 + filled); - yield* press(1); - } - - assert.strictEqual(asked.length, 1 + FILE_REVISIONS_CACHE_CAPACITY); - }), -); - -const environmentViewedServiceWithViewer = ( - revisions: Map, - getViewer: PullRequestProviderApi["getViewer"], -) => - makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [{ ...environmentViewedProvider(revisions, []), getViewer }], - }); - -it.effect("keeps one reader's marks on a host that names nobody", () => - Effect.gen(function* () { - const service = yield* environmentViewedServiceWithViewer( - new Map([["src/a.ts", "blob-a"]]), - () => Effect.succeed(""), - ); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "viewed" }, - ]); - }), -); - -it.effect("puts a listing and a press for one host on a single viewer lookup", () => - Effect.gen(function* () { - let viewerLookups = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), - getViewer: () => - Effect.gen(function* () { - viewerLookups += 1; - yield* Effect.yieldNow; - return "bilal"; - }), - }, - ], - }); - - yield* Effect.all([service.list({ state: "open" }), service.filesViewed(GITLAB_REFERENCE)], { - concurrency: 2, - }); - - assert.strictEqual(viewerLookups, 1); - }), -); - -it.effect("carries a bounded number of its own marks and says it held more", () => - Effect.gen(function* () { - const asked: Array> = []; - const paths = Array.from( - { length: PullRequestFilesViewed.MAX_FILES_VIEWED_ROWS + 40 }, - (_, at) => `src/f${String(at).padStart(4, "0")}.ts`, - ); - const service = yield* environmentViewedService( - new Map(paths.map((path) => [path, "blob"] as const)), - asked, - ); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: paths.map((path) => ({ path, viewed: true })), - }); - const read = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.lengthOf(read.files, PullRequestFilesViewed.MAX_FILES_VIEWED_ROWS); - assert.strictEqual(read.truncated, true); + id: "p2", + title: "other web", + workspaceRoot: "/b", + repository: "acme/other/_git/web", + provider: SourceControlProviderKind.make("azure-devops"), + host: "dev.azure.com", }), -); +]; -it.effect("records a press while the host is backing off", () => +const AZURE_PLATFORM = { projectId: "p1" as ProjectId, repository: "web", number: 1 }; +const AZURE_OTHER = { projectId: "p2" as ProjectId, repository: "web", number: 1 }; + +it.effect("keeps the marks of two Azure repositories of the same name apart", () => Effect.gen(function* () { - let viewerLookups = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), - getViewer: () => - Effect.sync(() => { - viewerLookups += 1; - return "bilal"; - }), - getFileRevisions: () => - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("gitlab"), - operation: "getFileRevisions", - reason: "rate-limited", - detail: "API rate limit exceeded.", - retryAt: 60 * 60 * 1_000, - }), - ), - }, - ], - }); + const service = yield* azureViewedService(AZURE_PAIR); yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, + ...AZURE_PLATFORM, files: [{ path: "src/a.ts", viewed: true }], }); - yield* TestClock.adjust("11 minutes"); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }); - assert.deepStrictEqual( - [...(yield* service.filesViewed(GITLAB_REFERENCE)).files].toSorted((left, right) => - left.path.localeCompare(right.path), - ), - [ - { path: "src/a.ts", state: "viewed" }, - { path: "src/b.ts", state: "viewed" }, - ], - ); - assert.strictEqual(viewerLookups, 2); + assert.deepStrictEqual((yield* service.filesViewed(AZURE_PLATFORM)).files, [ + { path: "src/a.ts", state: "viewed" }, + ]); + assert.deepStrictEqual((yield* service.filesViewed(AZURE_OTHER)).files, []); }), ); -it.effect("asks who is reading through a pause only for the press that is waiting on it", () => +it.effect("keeps environment marks apart from another change request's", () => Effect.gen(function* () { - let viewerLookups = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), - getViewer: () => - Effect.sync(() => { - viewerLookups += 1; - return "bilal"; - }), - listChangeRequests: () => - Effect.succeed({ items: [], truncated: false, continues: true }), - getFileRevisions: () => - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("gitlab"), - operation: "getFileRevisions", - reason: "rate-limited", - detail: "API rate limit exceeded.", - retryAt: 60 * 60 * 1_000, - }), - ), - }, - ], - }); + const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), []); yield* service.setFilesViewed({ ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true }], }); - assert.strictEqual(viewerLookups, 1); - yield* TestClock.adjust("11 minutes"); - - const listed = yield* Effect.flip(service.list({ state: "open", involvement: "all" })); - assert.strictEqual(listed._tag, "PullRequestOperationError"); - assert.strictEqual(viewerLookups, 1); + const other = yield* service.filesViewed({ ...GITLAB_REFERENCE, number: 2 }); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }); - assert.strictEqual(viewerLookups, 2); + assert.deepStrictEqual(other.files, []); }), ); -it.effect("does not ask a paused host who is reading again after the ask failed", () => - Effect.gen(function* () { - let viewerLookups = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), - getViewer: () => - Effect.suspend(() => { - viewerLookups += 1; - return Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("gitlab"), - operation: "getViewer", - reason: "failed", - detail: "glab exited with status 1", - }), - ); - }), - listChangeRequests: () => - Effect.succeed({ items: [], truncated: false, continues: true }), - runAction: () => - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("gitlab"), - operation: "runAction", - reason: "rate-limited", - detail: "API rate limit exceeded.", - retryAt: 60 * 60 * 1_000, - }), - ), - }, - ], - }); - - yield* Effect.flip(service.filesViewed(GITLAB_REFERENCE)); - assert.strictEqual(viewerLookups, 1); - yield* Effect.flip(service.runAction({ ...GITLAB_REFERENCE, action: "merge" })); - - yield* Effect.flip(service.list({ state: "open", involvement: "all" })); - yield* TestClock.adjust("11 minutes"); - yield* Effect.flip(service.list({ state: "open", involvement: "all" })); - assert.strictEqual(viewerLookups, 1); - }), -); +const environmentViewedServiceWithViewer = ( + revisions: Map, + getViewer: PullRequestProviderApi["getViewer"], +) => + makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: SourceControlProviderKind.make("gitlab"), + }), + ], + providers: [{ ...environmentViewedProvider(revisions, []), getViewer }], + }); -it.effect("refuses the marks when the host could not be asked who is reading", () => +it.effect("keeps one reader's marks on a host that names nobody", () => Effect.gen(function* () { - let answering = true; const service = yield* environmentViewedServiceWithViewer( new Map([["src/a.ts", "blob-a"]]), - () => - answering - ? Effect.succeed("bilal") - : Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("gitlab"), - operation: "getViewer", - reason: "failed", - detail: "glab exited with status 1", - }), - ), + () => Effect.succeed(""), ); yield* service.setFilesViewed({ ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true }], }); - answering = false; - yield* TestClock.adjust("11 minutes"); - - const read = yield* Effect.flip(service.filesViewed(GITLAB_REFERENCE)); - const write = yield* Effect.flip( - service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }), - ); - assert.strictEqual(read._tag, "PullRequestOperationError"); - assert.strictEqual(write._tag, "PullRequestOperationError"); - answering = true; assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ { path: "src/a.ts", state: "viewed" }, ]); }), ); -it.effect("refuses to track viewed files on a host that does not", () => +it.effect("carries a bounded number of its own marks and says it held more", () => Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - getFilesViewed: () => Effect.die("must not be called"), - setFilesViewed: () => Effect.die("must not be called"), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "group/project", number: 1 }; - - const read = yield* Effect.flip(service.filesViewed(reference)); - const write = yield* Effect.flip( - service.setFilesViewed({ ...reference, files: [{ path: "a.ts", viewed: true }] }), + const asked: Array> = []; + const paths = Array.from( + { length: PullRequestFilesViewed.MAX_FILES_VIEWED_ROWS + 40 }, + (_, at) => `src/f${String(at).padStart(4, "0")}.ts`, ); + const service = yield* environmentViewedService( + new Map(paths.map((path) => [path, "blob"] as const)), + asked, + ); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: paths.map((path) => ({ path, viewed: true })), + }); + const read = yield* service.filesViewed(GITLAB_REFERENCE); - assert.strictEqual(read._tag, "PullRequestOperationError"); - assert.strictEqual(write._tag, "PullRequestOperationError"); + assert.lengthOf(read.files, PullRequestFilesViewed.MAX_FILES_VIEWED_ROWS); + assert.strictEqual(read.truncated, true); }), ); @@ -7751,63 +3829,84 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s }), ); -it.effect.each([ - { action: "merge", environmentValue: true, projectValue: false, expected: false }, - { action: "merge", environmentValue: false, projectValue: true, expected: true }, - { action: "merge", environmentValue: true, projectValue: undefined, expected: true }, - { action: "merge", environmentValue: false, projectValue: undefined, expected: false }, - { action: "enable-auto-merge", environmentValue: true, projectValue: false, expected: false }, - { action: "enable-auto-merge", environmentValue: false, projectValue: true, expected: true }, - { action: "enable-auto-merge", environmentValue: true, projectValue: undefined, expected: true }, -] as const)( - "resolves agent credits for $action with environment=$environmentValue and project=$projectValue", - ({ action, environmentValue, projectValue, expected }) => - Effect.gen(function* () { - const calls: boolean[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" }), - ], - settings: { - ...DEFAULT_SERVER_SETTINGS, - removeAgentCreditsOnMerge: environmentValue, - projectSettingsOverrides: - projectValue === undefined - ? {} - : { - ["p1" as ProjectId]: { removeAgentCreditsOnMerge: projectValue }, - }, +it.effect("pauses uncached GitLab activity after only the reactions endpoint is rate limited", () => + Effect.gen(function* () { + let commandCalls = 0; + const provider = yield* GitLabPullRequestProvider.make.pipe( + Effect.provide( + GitLabPullRequestCli.layer.pipe( + Layer.provide(GitLabCli.layer), + Layer.provide( + TestSourceControlHost.layer({ + process: { + run: (input) => + Effect.suspend(() => { + commandCalls += 1; + return input.args[1] === "graphql" + ? Effect.fail( + new VcsProcessExitError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + exitCode: 1, + detail: "API rate limit exceeded.", + failureKind: "rate-limited", + }), + ) + : Effect.succeed(TestSourceControlHost.processOutput("[]")); + }), + }, + }), + ), + ), + ), + ); + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/repo", + repository: "acme/web", + provider: "gitlab", + host: "gitlab.example.test", + }), + ], + providers: [provider], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + + expect(yield* Effect.result(service.activity(reference))).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "PullRequestOperationError", + cause: { + _tag: "PullRequestProviderError", + reason: "rate-limited", + cause: { _tag: "GitLabCliRateLimitError" }, }, - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, - actions: ["merge", "enable-auto-merge"], - }, - getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), - getViewerPermissions: () => - Effect.succeed({ - actions: ["merge", "enable-auto-merge"], - comment: true, - resolve: true, - verdicts: ["comment"], - requestReviewers: false, - }), - runAction: (input) => - Effect.sync(() => { - calls.push(input.removeAgentCreditsOnMerge === true); - }), - }), - ], - }); - yield* service.runAction({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - action, - }); - assert.deepStrictEqual(calls, [expected]); - }), + }, + }); + const firstCommandCalls = commandCalls; + assert.isAbove(firstCommandCalls, 0); + + // A different merge request cannot reuse the first request's cached failure. + expect(yield* Effect.result(service.activity({ ...reference, number: 2 }))).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "PullRequestOperationError", + cause: { + _tag: "PullRequestProviderError", + reason: "rate-limited", + cause: { + _tag: "SourceControlRateLimitPausedError", + host: "gitlab.example.test", + }, + }, + }, + }); + assert.strictEqual(commandCalls, firstCommandCalls); + }), ); it.effect("expands only files the change request changes, across every slice of its diff", () => @@ -7885,47 +3984,3 @@ it.effect("expands only files the change request changes, across every slice of assert.deepStrictEqual(read, ["src/old.ts->src/new.ts", "gone.md->gone.md"]); }), ); - -it.effect("checks expanded files against a diff too large to cache without fetching it again", () => - Effect.gen(function* () { - let diffReads = 0; - const filler = `+${"x".repeat(300 * 1024)}`; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getDiff: () => - Effect.sync(() => { - diffReads += 1; - return { - patch: ["a.ts", "b.ts"] - .flatMap((name) => [ - `diff --git a/${name} b/${name}`, - `--- a/${name}`, - `+++ b/${name}`, - "@@ -0,0 +1 @@", - filler, - ]) - .join("\n"), - truncated: false, - nextCursor: null, - }; - }), - getDiffFileContents: () => Effect.succeed({ oldContents: "old", newContents: "new" }), - }), - ], - }); - const ref = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; - for (const name of ["a.ts", "b.ts"]) { - yield* service.diffFileContents({ - ...ref, - changeType: "change", - oldPath: name, - newPath: name, - }); - } - assert.strictEqual(diffReads, 1); - }), -); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index d2c31e5f7e51..612782523971 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -41,6 +41,7 @@ import { type PullRequestDiffFileContentsResult, type PullRequestDiffStat, type PullRequestDiffInput, + type PullRequestChangeBreakdown, type PullRequestFilesViewedResult, type PullRequestDiffResult, type PullRequestInvalidateInput, @@ -96,12 +97,16 @@ import { import * as PullRequestReadCache from "./PullRequestReadCache.ts"; import * as PullRequestProviderRegistry from "./PullRequestProviderRegistry.ts"; import * as ViewedFiles from "./pullRequestViewedFiles.ts"; +import { toChangeBreakdown } from "./pullRequestChangeBreakdown.ts"; export interface PullRequestMergeEvent extends PullRequestRef { readonly mergedAt: string; } -type PullRequestReading = Pick; +type PullRequestReading = Pick< + PullRequestSummary, + "state" | "isDraft" | "updatedAt" | "observedAt" +>; function supersedesReading(current: PullRequestReading | undefined, next: PullRequestReading) { if (current === undefined) return true; @@ -231,6 +236,9 @@ export class PullRequestService extends Context.Service< readonly setFilesViewed: ( input: PullRequestSetFilesViewedInput, ) => Effect.Effect; + readonly changeBreakdown: ( + input: PullRequestRef, + ) => Effect.Effect; readonly runAction: (input: PullRequestActionInput) => Effect.Effect; readonly update: (input: PullRequestUpdateInput) => Effect.Effect; readonly comment: (input: PullRequestCommentInput) => Effect.Effect; @@ -287,7 +295,7 @@ const ACTION_ACCESS_REFUSALS: Record = { reopen: "You need write access on this repository, or to have opened this change request, to reopen it.", "enable-auto-merge": - "You need write access on this repository to have it merged for you once it is ready.", + "Auto-merge needs write access on this repository, and the repository has to allow it.", "disable-auto-merge": "You need write access on this repository to stop it being merged for you once it is ready.", revert: "You need write access on this repository to open a revert pull request.", @@ -377,11 +385,11 @@ function listCursorAt( fetched: ReadonlyArray, deliveredCount: number, ): string { - const seenAt = [ + const seenAt = new Set([ ...(previous?.updatedBefore === boundary ? previous.seenAt : []), ...fetched.filter((item) => item.updatedAt === boundary).map((item) => item.number), - ]; - return `${boundary}|${(previous?.delivered ?? 0) + deliveredCount}|${seenAt.join(",")}`; + ]); + return `${boundary}|${(previous?.delivered ?? 0) + deliveredCount}|${[...seenAt].join(",")}`; } function isProviderUnusable(error: PullRequestProviderError): boolean { @@ -536,6 +544,9 @@ function withRateLimitBackoff( ...(api.getFilesViewed === undefined ? {} : { getFilesViewed: wrap("getFilesViewed", api.getFilesViewed) }), + ...(api.getFileStats === undefined + ? {} + : { getFileStats: wrap("getFileStats", api.getFileStats) }), ...(api.setFilesViewed === undefined ? {} : { setFilesViewed: interactive("setFilesViewed", api.setFilesViewed) }), @@ -1082,290 +1093,291 @@ export const make = Effect.gen(function* () { const searchVisibilityKey = (host: string, repository: string) => `${host}\n${repository.trim().toLowerCase()}`; - const listUncached: PullRequestService["Service"]["list"] = (input) => - Effect.gen(function* () { - const involvement = input.involvement ?? "all"; - const continuation = yield* decodeCursors(input.cursors); - const { - supported: projects, - unimplemented, - viewerRoots, - } = yield* listWorkspaceProjects(input); - const projectCounts = new Map(); - for (const { host } of projects) { - projectCounts.set(host, (projectCounts.get(host) ?? 0) + 1); - } + const listUncached: PullRequestService["Service"]["list"] = Effect.fn( + "PullRequestService.listUncached", + )(function* (input) { + const involvement = input.involvement ?? "all"; + const continuation = yield* decodeCursors(input.cursors); + const { supported: projects, unimplemented, viewerRoots } = yield* listWorkspaceProjects(input); + const projectCounts = new Map(); + for (const { host } of projects) { + projectCounts.set(host, (projectCounts.get(host) ?? 0) + 1); + } - const viewerResults = yield* resolveViewers(projects, viewerRoots); - const viewers: Record = {}; - for (const result of viewerResults) { - if (result.viewer !== null) viewers[result.host] = result.viewer; + const viewerResults = yield* resolveViewers(projects, viewerRoots); + const viewers: Record = {}; + for (const result of viewerResults) { + if (result.viewer !== null) viewers[result.host] = result.viewer; + } + + const providers: ReadonlyArray = [ + ...viewerResults.map((result) => { + const capabilities = projects.find((project) => project.host === result.host)?.api + .capabilities; + return { + host: result.host, + kind: result.kind, + searchesOnHost: capabilities?.search ?? false, + actions: capabilities?.actions ?? [], + projectCount: projectCounts.get(result.host) ?? 1, + configured: result.viewer !== null, + detail: result.error === null ? null : providerDetail(result.error), + }; + }), + ...[...unimplemented].map(([host, { kind, projectCount }]) => ({ + host, + kind, + searchesOnHost: false, + actions: [], + projectCount, + configured: false, + detail: "This host cannot be browsed here yet.", + })), + ]; + + const selected = + continuation === null + ? projects + : projects.filter(({ cursorKey }) => continuation.has(cursorKey)); + const readable = selected.filter(({ host }) => viewers[host] !== undefined); + const unreadable = selected + .filter(({ host }) => viewers[host] === undefined) + .map(({ project, repository }) => ({ + projectId: project.id, + projectTitle: project.title, + message: `${repository} could not be read.`, + })); + if (readable.length === 0) { + const errors = viewerResults.flatMap((result) => + result.error === null || !selected.some(({ host }) => host === result.host) + ? [] + : [result.error], + ); + const blocking = errors.find(isProviderUnusable) ?? errors[0]; + if (blocking) { + return yield* toPullRequestError("list")(blocking); } - const providers: ReadonlyArray = [ - ...viewerResults.map((result) => { - const capabilities = projects.find((project) => project.host === result.host)?.api - .capabilities; - return { - host: result.host, - kind: result.kind, - searchesOnHost: capabilities?.search ?? false, - actions: capabilities?.actions ?? [], - projectCount: projectCounts.get(result.host) ?? 1, - configured: result.viewer !== null, - detail: result.error === null ? null : providerDetail(result.error), - }; - }), - ...[...unimplemented].map(([host, { kind, projectCount }]) => ({ - host, - kind, - searchesOnHost: false, - actions: [], - projectCount, - configured: false, - detail: "This host cannot be browsed here yet.", - })), - ]; + return { + viewers: viewers as PullRequestListResult["viewers"], + providers, + entries: [], + errors: [], + truncated: false, + nextCursors: {}, + }; + } - const selected = - continuation === null - ? projects - : projects.filter(({ cursorKey }) => continuation.has(cursorKey)); - const readable = selected.filter(({ host }) => viewers[host] !== undefined); - const unreadable = selected - .filter(({ host }) => viewers[host] === undefined) - .map(({ project, repository }) => ({ - projectId: project.id, - projectTitle: project.title, - message: `${repository} could not be read.`, - })); - if (readable.length === 0) { - const errors = viewerResults.flatMap((result) => - result.error === null || !selected.some(({ host }) => host === result.host) - ? [] - : [result.error], - ); - const blocking = errors.find(isProviderUnusable) ?? errors[0]; - if (blocking) { - return yield* toPullRequestError("list")(blocking); - } + const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT; + const cursorOf = (project: SupportedProject): ListCursor | undefined => + continuation?.get(project.cursorKey); - return { - viewers: viewers as PullRequestListResult["viewers"], - providers, - entries: [], - errors: [], - truncated: false, - nextCursors: {}, - }; + const readRepository = (project: SupportedProject): Effect.Effect => { + { + const viewer = viewers[project.host]!; + const key = project.cursorKey; + const cursor = cursorOf(project); + return project.api + .listChangeRequests({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + state: input.state, + involvement, + viewer, + limit, + query: input.query, + filters: input.filters, + ...(cursor === undefined + ? {} + : { + cursor: { + updatedBefore: cursor.updatedBefore, + delivered: cursor.delivered, + seenAt: cursor.seenAt, + }, + }), + }) + .pipe( + observeRead, + Effect.map(({ value: page, observedAt }): RepositoryBatch => { + // The boundary instant was asked for inclusively, so the rows already sent at it + // come back with the slice. Dropping them here rather than asking for strictly + // older is what keeps their neighbours at the same instant from being skipped. + const items = + cursor === undefined + ? page.items + : page.items.filter( + (item) => + item.updatedAt !== cursor.updatedBefore || + !cursor.seenAt.includes(item.number), + ); + return { + key, + entries: items + .filter((item) => matchesRowFilters(item, input.filters, viewer)) + .map((item) => toEntry({ project, item, viewer, observedAt })), + errors: [], + truncated: page.truncated, + nextCursor: + page.continues && page.truncated + ? nextListCursor(cursor, page.items, items, page.cursorAdvance) + : null, + }; + }), + Effect.orElseSucceed((): RepositoryBatch => ({ + key, + entries: [], + errors: [ + { + projectId: project.project.id, + projectTitle: project.project.title, + message: `${project.repository} could not be read.`, + }, + ], + truncated: false, + nextCursor: null, + })), + ); } + }; - const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT; - const cursorOf = (project: SupportedProject): ListCursor | undefined => - continuation?.get(project.cursorKey); - - const readRepository = (project: SupportedProject): Effect.Effect => { - { - const viewer = viewers[project.host]!; - const key = project.cursorKey; - const cursor = cursorOf(project); - return project.api - .listChangeRequests({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - state: input.state, - involvement, - viewer, - limit, - query: input.query, - filters: input.filters, - ...(cursor === undefined - ? {} - : { - cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered }, - }), - }) - .pipe( - observeRead, - Effect.map(({ value: page, observedAt }): RepositoryBatch => { - // The boundary instant was asked for inclusively, so the rows already sent at it - // come back with the slice. Dropping them here rather than asking for strictly - // older is what keeps their neighbours at the same instant from being skipped. + const readTogether = ( + chunk: ReadonlyArray, + ): Effect.Effect> => { + const first = chunk[0]!; + const readAcross = first.api.listChangeRequestsAcross; + const separately = () => + Effect.forEach(chunk, readRepository, { concurrency: REPOSITORY_CONCURRENCY }); + if (readAcross === undefined) return separately(); + const viewer = viewers[first.host]!; + const cursor = cursorOf(first); + return readAcross({ + cwd: first.project.workspaceRoot, + host: first.host, + repositories: chunk.map((project) => project.repository), + state: input.state, + involvement, + viewer, + limit, + query: input.query, + filters: input.filters, + ...(cursor === undefined + ? {} + : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }), + }).pipe( + observeRead, + Effect.flatMap(({ value: page, observedAt }) => + Effect.flatMap(Clock.currentTimeMillis, (now) => { + const rows = new Map>(); + for (const [key, visibleAt] of searchVisibleAt) { + if (now - visibleAt > Duration.toMillis(SEARCH_VISIBILITY_TTL)) { + searchVisibleAt.delete(key); + } + } + for (const item of page.items) { + const key = item.repository.trim().toLowerCase(); + const held = rows.get(key); + if (held === undefined) rows.set(key, [item]); + else held.push(item); + searchVisibleAt.set(searchVisibilityKey(first.host, item.repository), now); + } + const boundary = page.items.reduce( + (oldest, item) => + oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest, + null, + ); + return Effect.forEach( + chunk, + (project): Effect.Effect => { + const fetched = rows.get(project.repository.trim().toLowerCase()) ?? []; + // GitHub does not index every repository for search — a renamed one answers for + // its old name with silence rather than with an error — so a repository the + // search said nothing at all about is read on its own, once, before it is + // believed. Only on its first slice: after that it has a boundary to carry on + // from, and silence past one means the rows are older rather than absent. + const lastVisible = searchVisibleAt.get( + searchVisibilityKey(project.host, project.repository), + ); + const searchIsKnownVisible = + !page.truncated && + lastVisible !== undefined && + now - lastVisible <= Duration.toMillis(SEARCH_VISIBILITY_TTL); + if ( + fetched.length === 0 && + cursorOf(project) === undefined && + !searchIsKnownVisible + ) { + return readRepository(project); + } + const cursorHere = cursorOf(project); const items = - cursor === undefined - ? page.items - : page.items.filter( + cursorHere === undefined + ? fetched + : fetched.filter( (item) => - item.updatedAt !== cursor.updatedBefore || - !cursor.seenAt.includes(item.number), + item.updatedAt !== cursorHere.updatedBefore || + !cursorHere.seenAt.includes(item.number), ); - return { - key, + return Effect.succeed({ + key: project.cursorKey, entries: items .filter((item) => matchesRowFilters(item, input.filters, viewer)) .map((item) => toEntry({ project, item, viewer, observedAt })), errors: [], truncated: page.truncated, nextCursor: - page.continues && page.truncated - ? nextListCursor(cursor, page.items, items, page.cursorAdvance) + page.truncated && boundary !== null + ? listCursorAt(cursorHere, boundary, fetched, items.length) : null, - }; - }), - Effect.orElseSucceed((): RepositoryBatch => ({ - key, - entries: [], - errors: [ - { - projectId: project.project.id, - projectTitle: project.project.title, - message: `${project.repository} could not be read.`, - }, - ], - truncated: false, - nextCursor: null, - })), + }); + }, + { concurrency: REPOSITORY_CONCURRENCY }, ); - } - }; - - const readTogether = ( - chunk: ReadonlyArray, - ): Effect.Effect> => { - const first = chunk[0]!; - const readAcross = first.api.listChangeRequestsAcross; - const separately = () => - Effect.forEach(chunk, readRepository, { concurrency: REPOSITORY_CONCURRENCY }); - if (readAcross === undefined) return separately(); - const viewer = viewers[first.host]!; - const cursor = cursorOf(first); - return readAcross({ - cwd: first.project.workspaceRoot, - host: first.host, - repositories: chunk.map((project) => project.repository), - state: input.state, - involvement, - viewer, - limit, - query: input.query, - filters: input.filters, - ...(cursor === undefined - ? {} - : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }), - }).pipe( - observeRead, - Effect.flatMap(({ value: page, observedAt }) => - Effect.flatMap(Clock.currentTimeMillis, (now) => { - const rows = new Map>(); - for (const [key, visibleAt] of searchVisibleAt) { - if (now - visibleAt > Duration.toMillis(SEARCH_VISIBILITY_TTL)) { - searchVisibleAt.delete(key); - } - } - for (const item of page.items) { - const key = item.repository.trim().toLowerCase(); - const held = rows.get(key); - if (held === undefined) rows.set(key, [item]); - else held.push(item); - searchVisibleAt.set(searchVisibilityKey(first.host, item.repository), now); - } - const boundary = page.items.reduce( - (oldest, item) => - oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest, - null, - ); - return Effect.forEach( - chunk, - (project): Effect.Effect => { - const fetched = rows.get(project.repository.trim().toLowerCase()) ?? []; - // GitHub does not index every repository for search — a renamed one answers for - // its old name with silence rather than with an error — so a repository the - // search said nothing at all about is read on its own, once, before it is - // believed. Only on its first slice: after that it has a boundary to carry on - // from, and silence past one means the rows are older rather than absent. - const lastVisible = searchVisibleAt.get( - searchVisibilityKey(project.host, project.repository), - ); - const searchIsKnownVisible = - !page.truncated && - lastVisible !== undefined && - now - lastVisible <= Duration.toMillis(SEARCH_VISIBILITY_TTL); - if ( - fetched.length === 0 && - cursorOf(project) === undefined && - !searchIsKnownVisible - ) { - return readRepository(project); - } - const cursorHere = cursorOf(project); - const items = - cursorHere === undefined - ? fetched - : fetched.filter( - (item) => - item.updatedAt !== cursorHere.updatedBefore || - !cursorHere.seenAt.includes(item.number), - ); - return Effect.succeed({ - key: project.cursorKey, - entries: items - .filter((item) => matchesRowFilters(item, input.filters, viewer)) - .map((item) => toEntry({ project, item, viewer, observedAt })), - errors: [], - truncated: page.truncated, - nextCursor: - page.truncated && boundary !== null - ? listCursorAt(cursorHere, boundary, fetched, items.length) - : null, - }); - }, - { concurrency: REPOSITORY_CONCURRENCY }, - ); - }), - ), - Effect.catch(separately), - ); - }; + }), + ), + Effect.catch(separately), + ); + }; - const together = new Map>(); - const separate: Array = []; - for (const project of readable) { - if (project.api.listChangeRequestsAcross === undefined) { - separate.push(project); - continue; - } - const key = `${project.host}\n${cursorOf(project)?.updatedBefore ?? ""}`; - const group = together.get(key); - if (group === undefined) together.set(key, [project]); - else group.push(project); + const together = new Map>(); + const separate: Array = []; + for (const project of readable) { + if (project.api.listChangeRequestsAcross === undefined) { + separate.push(project); + continue; } - const reads: Array>> = separate.map((project) => - readRepository(project).pipe(Effect.map((batch) => [batch])), - ); - for (const group of together.values()) { - for (let start = 0; start < group.length; start += REPOSITORY_SEARCH_CHUNK) { - reads.push(readTogether(group.slice(start, start + REPOSITORY_SEARCH_CHUNK))); - } + const key = `${project.host}\n${cursorOf(project)?.updatedBefore ?? ""}`; + const group = together.get(key); + if (group === undefined) together.set(key, [project]); + else group.push(project); + } + const reads: Array>> = separate.map((project) => + readRepository(project).pipe(Effect.map((batch) => [batch])), + ); + for (const group of together.values()) { + for (let start = 0; start < group.length; start += REPOSITORY_SEARCH_CHUNK) { + reads.push(readTogether(group.slice(start, start + REPOSITORY_SEARCH_CHUNK))); } - const batches = (yield* Effect.all(reads, { concurrency: REPOSITORY_CONCURRENCY })).flat(); + } + const batches = (yield* Effect.all(reads, { concurrency: REPOSITORY_CONCURRENCY })).flat(); - const nextCursors: Record = {}; - for (const batch of batches) { - if (batch.nextCursor !== null) nextCursors[batch.key] = batch.nextCursor; - } + const nextCursors: Record = {}; + for (const batch of batches) { + if (batch.nextCursor !== null) nextCursors[batch.key] = batch.nextCursor; + } - return { - viewers: viewers as PullRequestListResult["viewers"], - providers, - entries: batches - .flatMap((batch) => batch.entries) - .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)), - errors: [...unreadable, ...batches.flatMap((batch) => batch.errors)], - truncated: batches.some((batch) => batch.truncated), - nextCursors, - }; - }); + return { + viewers: viewers as PullRequestListResult["viewers"], + providers, + entries: batches + .flatMap((batch) => batch.entries) + .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)), + errors: [...unreadable, ...batches.flatMap((batch) => batch.errors)], + truncated: batches.some((batch) => batch.truncated), + nextCursors, + }; + }); const viewerOf = (project: SupportedProject): Effect.Effect => routingCredential.pipe( @@ -1399,36 +1411,34 @@ export const make = Effect.gen(function* () { return { ...identity, host, provider: "github" as const }; }); - const withRoutingCredential: PullRequestService["Service"]["withRoutingCredential"] = ( - input, - operation, - ) => - Effect.gen(function* () { - if (input.expectedAccountId === undefined) return yield* operation; - const rejected = () => - new PullRequestOperationError({ - operation: "routeIdentity", - detail: "The GitHub account could not be verified before starting the operation.", - }); - const project = yield* requireProject(input).pipe(Effect.mapError(rejected)); - const api = registry.get(project.api.kind); - if ( - api?.withVerifiedCredential === undefined || - input.host?.toLowerCase() !== project.host.toLowerCase() - ) { - return yield* rejected(); - } - const result = yield* api - .withVerifiedCredential( - { cwd: project.project.workspaceRoot, host: project.host }, - (identity) => - identity.accountId === input.expectedAccountId - ? operation.pipe(Effect.provideService(routingCredential, identity), Effect.result) - : Effect.fail(rejected()), - ) - .pipe(Effect.catchTags({ PullRequestProviderError: () => Effect.fail(rejected()) })); - return yield* Effect.fromResult(result); - }); + const withRoutingCredential: PullRequestService["Service"]["withRoutingCredential"] = Effect.fn( + "PullRequestService.withRoutingCredential", + )(function* (input, operation) { + if (input.expectedAccountId === undefined) return yield* operation; + const rejected = () => + new PullRequestOperationError({ + operation: "routeIdentity", + detail: "The GitHub account could not be verified before starting the operation.", + }); + const project = yield* requireProject(input).pipe(Effect.mapError(rejected)); + const api = registry.get(project.api.kind); + if ( + api?.withVerifiedCredential === undefined || + input.host?.toLowerCase() !== project.host.toLowerCase() + ) { + return yield* rejected(); + } + const result = yield* api + .withVerifiedCredential( + { cwd: project.project.workspaceRoot, host: project.host }, + (identity) => + identity.accountId === input.expectedAccountId + ? operation.pipe(Effect.provideService(routingCredential, identity), Effect.result) + : Effect.fail(rejected()), + ) + .pipe(Effect.catchTags({ PullRequestProviderError: () => Effect.fail(rejected()) })); + return yield* Effect.fromResult(result); + }); const routing = Effect.fn("PullRequestService.routing")(function* (input: PullRequestRef) { const project = yield* requireProject(input); @@ -1778,7 +1788,7 @@ export const make = Effect.gen(function* () { // Deliberately not `invalidatedByMutation`: ticking a file off says nothing about the // change request, and dropping a 300-file diff on every checkbox is the whole cost of // the feature. - Effect.tap(() => Effect.sync(() => bumpFilesViewedEpoch(ref))), + Effect.ensuring(Effect.sync(() => bumpFilesViewedEpoch(ref))), ), ), ); @@ -2354,79 +2364,79 @@ export const make = Effect.gen(function* () { * The line counts for rows already on the page, which the listing left out because on GitHub * they cost more than everything else on the row put together. */ - const listStatsUncached: PullRequestService["Service"]["listStats"] = (input) => - Effect.gen(function* () { - if (input.refs.length === 0) return { stats: [] }; - const { supported } = yield* listWorkspaceProjects({}); - const byProject = new Map(supported.map((project) => [project.project.id, project])); - const wanted = new Map< - string, - { readonly project: SupportedProject; readonly number: number } - >(); - for (const ref of input.refs) { - const project = byProject.get(ref.projectId); - if ( - project === undefined || - project.api.listChangeRequestStats === undefined || - normalizeSourceControlRepository(project.repository, project.api.kind) !== - normalizeSourceControlRepository(ref.repository, project.api.kind) - ) { - continue; - } - wanted.set(`${project.project.id} ${ref.number}`, { project, number: ref.number }); - } - const byHost = new Map>(); - for (const entry of wanted.values()) { - const held = byHost.get(entry.project.host); - if (held === undefined) byHost.set(entry.project.host, [entry]); - else held.push(entry); + const listStatsUncached: PullRequestService["Service"]["listStats"] = Effect.fn( + "PullRequestService.listStatsUncached", + )(function* (input) { + if (input.refs.length === 0) return { stats: [] }; + const { supported } = yield* listWorkspaceProjects({}); + const byProject = new Map(supported.map((project) => [project.project.id, project])); + const wanted = new Map< + string, + { readonly project: SupportedProject; readonly number: number } + >(); + for (const ref of input.refs) { + const project = byProject.get(ref.projectId); + if ( + project === undefined || + project.api.listChangeRequestStats === undefined || + normalizeSourceControlRepository(project.repository, project.api.kind) !== + normalizeSourceControlRepository(ref.repository, project.api.kind) + ) { + continue; } - const stats = yield* Effect.forEach( - [...byHost.values()], - (entries) => { - const first = entries[0]!; - const readStats = first.project.api.listChangeRequestStats; - if (readStats === undefined) - return Effect.succeed>([]); - const projectsByRepository = new Map( - entries.map((entry) => [ - `${normalizeSourceControlRepository(entry.project.repository, entry.project.api.kind)} ${entry.number}`, - entry.project, - ]), - ); - return readStats({ - cwd: first.project.project.workspaceRoot, - host: first.project.host, - changeRequests: entries.map((entry) => ({ - repository: entry.project.repository, - number: entry.number, - })), - }).pipe( - Effect.map((read) => - read.flatMap((stat): ReadonlyArray => { - const project = projectsByRepository.get( - `${normalizeSourceControlRepository(stat.repository, first.project.api.kind)} ${stat.number}`, - ); - return project === undefined - ? [] - : [ - { - projectId: project.project.id, - repository: project.repository, - number: stat.number, - additions: stat.additions, - deletions: stat.deletions, - }, - ]; - }), - ), - Effect.orElseSucceed((): ReadonlyArray => []), - ); - }, - { concurrency: REPOSITORY_CONCURRENCY }, - ); - return { stats: stats.flat() }; - }); + wanted.set(`${project.project.id} ${ref.number}`, { project, number: ref.number }); + } + const byHost = new Map>(); + for (const entry of wanted.values()) { + const held = byHost.get(entry.project.host); + if (held === undefined) byHost.set(entry.project.host, [entry]); + else held.push(entry); + } + const stats = yield* Effect.forEach( + [...byHost.values()], + (entries) => { + const first = entries[0]!; + const readStats = first.project.api.listChangeRequestStats; + if (readStats === undefined) return Effect.succeed>([]); + const projectsByRepository = new Map( + entries.map((entry) => [ + `${normalizeSourceControlRepository(entry.project.repository, entry.project.api.kind)} ${entry.number}`, + entry.project, + ]), + ); + return readStats({ + cwd: first.project.project.workspaceRoot, + host: first.project.host, + changeRequests: entries.map((entry) => ({ + repository: entry.project.repository, + number: entry.number, + })), + }).pipe( + Effect.map((read) => + read.flatMap((stat): ReadonlyArray => { + const project = projectsByRepository.get( + `${normalizeSourceControlRepository(stat.repository, first.project.api.kind)} ${stat.number}`, + ); + return project === undefined + ? [] + : [ + { + projectId: project.project.id, + repository: project.repository, + number: stat.number, + additions: stat.additions, + deletions: stat.deletions, + }, + ]; + }), + ), + Effect.orElseSucceed((): ReadonlyArray => []), + ); + }, + { concurrency: REPOSITORY_CONCURRENCY }, + ); + return { stats: stats.flat() }; + }); const revalidate = (read: Effect.Effect) => Effect.context().pipe( @@ -2892,10 +2902,12 @@ export const make = Effect.gen(function* () { } detailStates.set(scope, { state: next.state, + isDraft: next.isDraft, updatedAt: next.updatedAt, observedAt: next.observedAt, }); - return current?.state !== next.state && ref.host !== undefined + return (current?.state !== next.state || current?.isDraft !== next.isDraft) && + ref.host !== undefined ? PubSub.publish(stateChanges, { host: ref.host, repository: ref.repository, @@ -2907,22 +2919,34 @@ export const make = Effect.gen(function* () { // must never outrank a reading this environment made itself. const reportedStates = new Map< string, - { readonly state: PullRequestState; readonly atMs: number } + { + readonly state: PullRequestState; + readonly isDraft: boolean | undefined; + readonly atMs: number; + } >(); - const reportState: PullRequestService["Service"]["reportState"] = ({ reference, state }) => + const reportState: PullRequestService["Service"]["reportState"] = ({ + reference, + state, + isDraft, + }) => Effect.all([canonicalRef(reference), Clock.currentTimeMillis]).pipe( Effect.flatMap(([ref, nowMs]) => Effect.suspend(() => { const scope = refScope(ref); const reported = reportedStates.get(scope); - if (reported?.state === state && nowMs - reported.atMs < REPORTED_STATE_DEDUPE_MS) + if ( + reported?.state === state && + reported.isDraft === isDraft && + nowMs - reported.atMs < REPORTED_STATE_DEDUPE_MS + ) return Effect.void; reportedStates.delete(scope); if (reportedStates.size >= REF_EPOCH_CAPACITY) { const oldest = reportedStates.keys().next().value; if (oldest !== undefined) reportedStates.delete(oldest); } - reportedStates.set(scope, { state, atMs: nowMs }); + reportedStates.set(scope, { state, isDraft, atMs: nowMs }); return ref.host === undefined ? Effect.void : PubSub.publish(stateChanges, { @@ -2967,6 +2991,32 @@ export const make = Effect.gen(function* () { }, ); + const changeBreakdownCache = yield* Cache.makeWith( + (key: string) => { + const ref = refOfCacheKey(key); + return requireProject(ref).pipe( + Effect.flatMap((project) => { + const read = project.api.getFileStats; + if (read === undefined) return Effect.succeed(null); + return read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: ref.number, + }).pipe( + Effect.map(toChangeBreakdown), + Effect.mapError(toPullRequestError("changeBreakdown")), + ); + }), + ); + }, + { + capacity: DETAIL_CACHE_CAPACITY, + timeToLive: (exit, key) => + Exit.isSuccess(exit) ? detailTimeToLive(lastGoodSummary.peek(key)?.state) : Duration.zero, + }, + ); + const previewCache = yield* Cache.makeWith( (key: string) => { return previewUncached(refOfCacheKey(key)); @@ -3275,30 +3325,29 @@ export const make = Effect.gen(function* () { const fileKey = (oldPath: string, newPath: string) => JSON.stringify([oldPath, newPath]); const sliceKey = (input: PullRequestDiffInput) => JSON.stringify([refCacheKey(input), input.cursor ?? null, input.commit ?? null]); - const read = (input: PullRequestDiffInput) => - Effect.gen(function* () { - const key = sliceKey(input); - const now = yield* Clock.currentTimeMillis; - const cached = held.get(key); - if (cached !== undefined && now - cached.at <= ttlMs) return cached; - const slice = yield* diff(input); - const entry = { - at: now, - files: new Set( - parseDiffFilePaths(slice.patch).map((file) => - fileKey(file.oldPath ?? file.newPath ?? "", file.newPath ?? file.oldPath ?? ""), - ), + const read = Effect.fn("PullRequestService.read")(function* (input: PullRequestDiffInput) { + const key = sliceKey(input); + const now = yield* Clock.currentTimeMillis; + const cached = held.get(key); + if (cached !== undefined && now - cached.at <= ttlMs) return cached; + const slice = yield* diff(input); + const entry = { + at: now, + files: new Set( + parseDiffFilePaths(slice.patch).map((file) => + fileKey(file.oldPath ?? file.newPath ?? "", file.newPath ?? file.oldPath ?? ""), ), - nextCursor: slice.nextCursor, - }; - held.delete(key); - if (held.size >= DIFF_CACHE_CAPACITY) { - const oldest = held.keys().next().value; - if (oldest !== undefined) held.delete(oldest); - } - held.set(key, entry); - return entry; - }); + ), + nextCursor: slice.nextCursor, + }; + held.delete(key); + if (held.size >= DIFF_CACHE_CAPACITY) { + const oldest = held.keys().next().value; + if (oldest !== undefined) held.delete(oldest); + } + held.set(key, entry); + return entry; + }); return { read: credentialCached(read), fileKey }; })(); /** @@ -3377,6 +3426,9 @@ export const make = Effect.gen(function* () { diffFileContents, filesViewed: credentialCached(filesViewed), setFilesViewed, + changeBreakdown: credentialCached((input) => + Cache.get(changeBreakdownCache, refCacheKey(input)), + ), runAction: runActionAndInvalidate, update: invalidatedByMutation(update), comment: invalidatedByMutation(comment), diff --git a/apps/server/src/pullRequest/pullRequestChangeBreakdown.ts b/apps/server/src/pullRequest/pullRequestChangeBreakdown.ts new file mode 100644 index 000000000000..eaaf50c0d868 --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestChangeBreakdown.ts @@ -0,0 +1,21 @@ +import type { PullRequestChangeBreakdown } from "@t3tools/contracts"; +import { classifyChangedFile } from "@t3tools/shared/changedFileKind"; +import type { ProviderFileStats } from "@t3tools/source-control-core/server/PullRequestProvider"; + +export function toChangeBreakdown(stats: ProviderFileStats): PullRequestChangeBreakdown { + if (stats.truncated) return null; + const empty = () => ({ additions: 0, deletions: 0 }); + const breakdown = { + code: empty(), + tests: empty(), + mocks: empty(), + generated: empty(), + docs: empty(), + }; + for (const file of stats.files) { + const bucket = breakdown[classifyChangedFile(file.path)]; + bucket.additions += file.additions; + bucket.deletions += file.deletions; + } + return breakdown; +} diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideInput.test.ts b/apps/server/src/pullRequest/pullRequestReviewGuideInput.test.ts deleted file mode 100644 index 3db791492c6d..000000000000 --- a/apps/server/src/pullRequest/pullRequestReviewGuideInput.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import type { PullRequestActivity, PullRequestReviewThread } from "@t3tools/contracts"; - -import { settledReviewSummary } from "./pullRequestReviewGuideInput.ts"; - -const thread = ( - overrides: Partial & { body?: string; login?: string }, -): PullRequestReviewThread => ({ - id: overrides.id ?? "t1", - path: overrides.path ?? "src/a.ts", - line: 1, - side: "right", - isResolved: overrides.isResolved ?? false, - isOutdated: overrides.isOutdated ?? false, - comments: [ - { - id: "c1", - author: { login: overrides.login ?? "macroscope[bot]", name: null, avatarUrl: null }, - body: overrides.body ?? "Possible null dereference", - createdAt: "2026-10-01T00:00:00.000Z", - url: null, - }, - ], -}); - -const activity = (reviewThreads: ReadonlyArray): PullRequestActivity => ({ - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads, - commits: [], -}); - -describe("settledReviewSummary", () => { - it("lists resolved and outdated threads and leaves open ones for the guide", () => { - const summary = settledReviewSummary( - activity([ - thread({ id: "open", path: "src/open.ts", body: "Still open" }), - thread({ id: "resolved", path: "src/a.ts", isResolved: true }), - thread({ - id: "outdated", - path: "src/b.ts", - isOutdated: true, - login: "julius", - body: "Rename this \n\nplease", - }), - ]), - ); - - expect(summary).toBe( - [ - "- src/a.ts (resolved, macroscope[bot]): Possible null dereference", - "- src/b.ts (outdated, julius): Rename this please", - ].join("\n"), - ); - }); -}); diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideInput.ts b/apps/server/src/pullRequest/pullRequestReviewGuideInput.ts index 353c10eb5260..2f483490e86e 100644 --- a/apps/server/src/pullRequest/pullRequestReviewGuideInput.ts +++ b/apps/server/src/pullRequest/pullRequestReviewGuideInput.ts @@ -11,7 +11,7 @@ function oneLine(text: string): string { return flat.length > SETTLED_REMARK_CHARS ? `${flat.slice(0, SETTLED_REMARK_CHARS)}…` : flat; } -export function settledReviewSummary(activity: PullRequestActivity): string { +function settledReviewSummary(activity: PullRequestActivity): string { return activity.reviewThreads .filter((thread) => thread.isResolved || thread.isOutdated) .flatMap((thread) => { diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts index 233013ac7146..69479b1ccfee 100644 --- a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts +++ b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts @@ -121,18 +121,6 @@ describe("normalizeReviewGuide", () => { expect(result.stops).toHaveLength(1); }); - it("keeps a long excerpt to its first 25 lines", () => { - const long = parseReviewGuideChange( - "big.ts\0", - "--- a/big.ts\n+++ b/big.ts\n@@ -0,0 +1,100 @@\n", - ); - const result = normalizeReviewGuide( - guide({ stops: [stop({ excerpts: [excerpt("big.ts", 1, 100)] })] }), - long, - ); - expect(result.stops[0]?.excerpts).toEqual([excerpt("big.ts", 1, 25)]); - }); - it("drops notes outside the stop's remaining excerpts", () => { const result = normalizeReviewGuide( guide({ diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index 59402ee1d9c7..fc2913e3efd4 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -19,13 +19,13 @@ import type { PullRequestError, SupportedProject } from "./PullRequestService.ts const FILE_REVISIONS_CACHE_TTL = Duration.seconds(60); const FILE_REVISIONS_STALE_WINDOW = Duration.minutes(10); -export const FILE_REVISIONS_CACHE_CAPACITY = 64; +const FILE_REVISIONS_CACHE_CAPACITY = 64; /** * Bounds a single scope, not how many scopes are held: a reader ticking one file after * another renews the same scope and grows it without limit otherwise. */ -export const MAX_FILE_REVISION_PATHS = 1_000; +const MAX_FILE_REVISION_PATHS = 1_000; interface FileRevisionsDependencies { readonly runFork: (effect: Effect.Effect) => unknown; @@ -196,11 +196,11 @@ export const make = (dependencies: Dependencies) => { cause, }); - const environmentFilesViewed = ( - project: SupportedProject, - ref: PullRequestRef, - ): Effect.Effect => - Effect.gen(function* () { + const environmentFilesViewed = Effect.fn("pullRequestViewedFiles.environmentFilesViewed")( + function* ( + project: SupportedProject, + ref: PullRequestRef, + ): Effect.fn.Return { const viewer = yield* requiredViewerOf(project, "filesViewed"); const held = yield* filesViewedStore .list(filesViewedScope(project, ref.number, viewer)) @@ -234,7 +234,8 @@ export const make = (dependencies: Dependencies) => { }), truncated: held.truncated, }; - }); + }, + ); /** * A tick's host round trip is slower than an untick's, so unordered presses could finish @@ -271,11 +272,11 @@ export const make = (dependencies: Dependencies) => { ); }); - const environmentSetFilesViewed = ( - project: SupportedProject, - input: PullRequestSetFilesViewedInput, - ): Effect.Effect => - Effect.gen(function* () { + const environmentSetFilesViewed = Effect.fn("pullRequestViewedFiles.environmentSetFilesViewed")( + function* ( + project: SupportedProject, + input: PullRequestSetFilesViewedInput, + ): Effect.fn.Return { const viewer = yield* requiredViewerOf(project, "setFilesViewed"); const cleared = input.files.filter((file) => file.viewed).map((file) => file.path); const revisions = @@ -304,7 +305,8 @@ export const make = (dependencies: Dependencies) => { viewedAt, }) .pipe(Effect.mapError(toFilesViewedStoreError("setFilesViewed"))); - }); + }, + ); const filesViewed = (input: PullRequestRef) => requireProject(input).pipe( diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 285f2cfe1f9b..64b71140fdcf 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -287,45 +287,6 @@ const makeTestRelay = Effect.fnUntraced(function* ( }); describe("AgentAwarenessRelay", () => { - it("ignores transcript and tool updates but retains activity and metadata changes", () => { - for (const type of [ - "message.updated", - "turn-item.updated", - "provider-turn.updated", - "thread.visited", - "thread.pinned", - ] as const) { - assert.isFalse(AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ type })); - } - for (const type of [ - "run.created", - "run.updated", - "runtime-request.updated", - "subagent.updated", - "provider-thread.updated", - "thread.metadata-updated", - "thread.model-selection-updated", - "thread.provider-switched", - "thread.archived", - "thread.unarchived", - "thread.deleted", - ] as const) { - assert.isTrue(AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ type })); - } - }); - - it("does not publish imported thread creation as new agent activity", () => { - assert.isFalse( - AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ - type: "thread.created", - payload: { historyOrigin: "v1_import" }, - }), - ); - assert.isTrue( - AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ type: "thread.created", payload: {} }), - ); - }); - it.effect("coalesces queued updates and reruns a thread dirtied during publishing", () => Effect.gen(function* () { const started = yield* Deferred.make(); @@ -390,22 +351,6 @@ describe("AgentAwarenessRelay", () => { }), ); - it.effect("stops before shell reads when disabled and republishes after re-enabling", () => - Effect.gen(function* () { - const { relay, secrets, secretReads, shellReads, publications } = yield* makeTestRelay(); - yield* relay.publishThread(THREAD_ID); - yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("false")); - const previousReads = secretReads.length; - yield* relay.publishThread(THREAD_ID); - assert.deepEqual(secretReads.slice(previousReads), [PUBLISH_AGENT_ACTIVITY_SECRET]); - assert.equal(shellReads.length, 1); - assert.equal(publications.length, 1); - yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("true")); - yield* relay.publishThread(THREAD_ID); - assert.equal(publications.length, 2); - }), - ); - it.effect("republishes unchanged state with fresh credentials after relinking", () => Effect.gen(function* () { const { relay, secrets, publications } = yield* makeTestRelay(); @@ -428,347 +373,74 @@ describe("AgentAwarenessRelay", () => { }), ); - it.effect("a stalled response body releases later threads and uses bounded retries", () => - Effect.gen(function* () { - const bodyStarted = Promise.withResolvers(); - const secondRead = yield* Deferred.make(); - const currentShell = yield* Ref.make(shell()); - const { relay, publications } = yield* makeTestRelay({ - readShell: (threadId) => - threadId === SECOND_THREAD_ID - ? Deferred.succeed(secondRead, undefined).pipe( - Effect.as(shell({ id: SECOND_THREAD_ID })), - ) - : Ref.get(currentShell), - respond: (attempt) => { - if (attempt !== 1) return new Response("relay unavailable", { status: 503 }); - const response = new Response(null, { - headers: { "content-type": "application/json" }, - }); - return Object.assign(response, { - arrayBuffer: () => { - bodyStarted.resolve(); - return new Promise(() => {}); - }, - }); - }, - }); - const first = yield* relay.publishThread(THREAD_ID).pipe(Effect.forkChild); - yield* Effect.promise(() => bodyStarted.promise); - const second = yield* relay.publishThread(SECOND_THREAD_ID).pipe(Effect.forkChild); - yield* TestClock.adjust("9999 millis"); - assert.equal(publications.length, 1); - yield* Ref.set(currentShell, shell({ title: "Latest title" })); - yield* TestClock.adjust("1 millis"); - assert.isTrue(yield* Deferred.isDone(secondRead)); - yield* Fiber.join(first); - yield* Fiber.join(second); - assert.equal(publications.length, 2); - assert.isTrue(publications[1]?.url.includes(SECOND_THREAD_ID) ?? false); - for (const delay of [1_000, 2_000, 4_000, 8_000, 16_000]) { - const attempts = publications.length; - yield* TestClock.adjust(delay - 1); - yield* relay.drain; - assert.equal(publications.length, attempts); - yield* TestClock.adjust(1); - yield* relay.drain; - assert.equal(publications.length, attempts + 2); - assert.equal(publications[attempts]?.state?.threadTitle, "Latest title"); - } - yield* TestClock.adjust("1 minute"); - yield* relay.drain; - assert.equal(publications.length, 12); - }), - ); - - it.effect("retries a failed final state without another thread event", () => - Effect.gen(function* () { - const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({ - respond: (attempt) => - attempt === 2 - ? new Response("relay unavailable", { status: 503 }) - : Response.json({ ok: true, deliveries: [] }), - }); - yield* relay.publishThread(THREAD_ID); - yield* Ref.set( - currentShell, - shell({ - status: "completed", - latestRunCompletedAt: DateTime.add(yield* DateTime.now, { seconds: 1 }), - }), - ); - yield* relay.publishThread(THREAD_ID); - assert.equal(publications.length, 2); - yield* TestClock.adjust("1 second"); - yield* relay.drain; - assert.equal(publications.length, 3); - assert.equal(publications[2]?.state?.phase, "completed"); - yield* TestClock.adjust("1 minute"); - yield* relay.drain; - assert.equal(shellReads.length, 3); - }), - ); - - it.effect("retries the latest shell and credentials with first-completion confirmation", () => - Effect.gen(function* () { - const { relay, secrets, currentShell, publications } = yield* makeTestRelay({ - respond: (attempt) => - attempt === 1 - ? new Response("relay unavailable", { status: 503 }) - : Response.json({ ok: true, deliveries: [] }), - }); - yield* relay.publishThread(THREAD_ID); - yield* Ref.set( - currentShell, - shell({ - status: "completed", - title: "Final title", - latestRunCompletedAt: DateTime.add(yield* DateTime.now, { seconds: 1 }), - }), - ); - yield* secrets.set( - RELAY_ENVIRONMENT_CREDENTIAL_SECRET, - new TextEncoder().encode("credential-2"), - ); - yield* TestClock.adjust("1 second"); - yield* relay.drain; - assert.equal(publications.length, 1); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.equal(publications.length, 2); - assert.equal(publications[1]?.authorization, "Bearer credential-2"); - assert.equal(publications[1]?.state?.phase, "completed"); - assert.equal(publications[1]?.state?.threadTitle, "Final title"); - }), - ); - - it.effect("cancels a stale retry when a newer update publishes successfully", () => - Effect.gen(function* () { - const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({ - respond: (attempt) => - attempt === 1 - ? new Response("relay unavailable", { status: 503 }) - : Response.json({ ok: true, deliveries: [] }), - }); - yield* relay.publishThread(THREAD_ID); - yield* Ref.set(currentShell, shell({ title: "New title" })); - yield* relay.publishThread(THREAD_ID); - assert.equal(publications.length, 2); - assert.equal(publications[1]?.state?.threadTitle, "New title"); - yield* TestClock.adjust("1 minute"); - yield* relay.drain; - assert.equal(shellReads.length, 2); - }), - ); - - it.effect.each(["disable", "unlink"] as const)( - "stops retries before shell reads after %s", - (change) => + it.effect.each(["completed", "failed"] as const)( + "publishes a tombstone when a %s thread is settled through a domain event", + (status) => Effect.gen(function* () { - const { relay, secrets, shellReads, publications } = yield* makeTestRelay({ - respond: () => new Response("relay unavailable", { status: 503 }), + const events = yield* Queue.unbounded(); + const pulls = yield* Queue.unbounded(); + const { relay, currentShell, publications } = yield* makeTestRelay({ + domainEvents: Stream.fromEffectRepeat( + Queue.offer(pulls, undefined).pipe(Effect.andThen(Queue.take(events))), + ), }); + yield* relay.start(); + yield* Queue.take(pulls); yield* relay.publishThread(THREAD_ID); - if (change === "disable") { - yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("false")); - } else { - yield* secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET); - } - yield* TestClock.adjust("1 second"); - yield* relay.drain; - yield* TestClock.adjust("1 minute"); - yield* relay.drain; - assert.equal(publications.length, 1); - assert.equal(shellReads.length, 1); - }), - ); - - it.effect("recovers from a transient configuration read failure", () => - Effect.gen(function* () { - let failRead = false; - const { relay, shellReads, publications } = yield* makeTestRelay({ - failSecretRead: (name) => failRead && name === PUBLISH_AGENT_ACTIVITY_SECRET, - }); - failRead = true; - yield* relay.publishThread(THREAD_ID); - assert.equal(shellReads.length, 0); - failRead = false; - yield* TestClock.adjust("1 second"); - yield* relay.drain; - assert.equal(publications.length, 1); - assert.equal(publications[0]?.state?.phase, "running"); - }), - ); + const terminal = shell({ status }); + yield* Ref.set(currentShell, terminal); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications[1]?.state?.phase, status); - it.effect("bounds retry attempts and resets the budget for a newer update", () => - Effect.gen(function* () { - const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({ - respond: () => new Response("relay unavailable", { status: 503 }), - }); - yield* relay.publishThread(THREAD_ID); - let attempts = 1; - for (const delay of [1_000, 2_000, 4_000, 8_000, 16_000]) { - yield* TestClock.adjust(delay - 1); - yield* relay.drain; - assert.equal(publications.length, attempts); - yield* TestClock.adjust(1); + const now = yield* DateTime.now; + const settled = { + ...terminal, + settledOverride: "settled" as const, + settledAt: now, + lastVisitedAt: null, + }; + yield* Ref.set(currentShell, settled); + yield* Queue.offer(events, { + id: EventId.make("event:settled"), + type: "thread.settled", + threadId: THREAD_ID, + occurredAt: now, + payload: settled, + }); + yield* Queue.take(pulls); yield* relay.drain; - attempts += 1; - assert.equal(publications.length, attempts); - } - yield* TestClock.adjust("1 minute"); - yield* relay.drain; - assert.equal(publications.length, 6); - assert.equal(shellReads.length, 6); - yield* Ref.set(currentShell, shell({ title: "New update" })); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust("1 second"); - yield* relay.drain; - assert.equal(publications.length, 8); - assert.equal(publications[7]?.state?.threadTitle, "New update"); - }), - ); - - it.effect("retries a confirmed completion without restarting its confirmation window", () => - Effect.gen(function* () { - const { relay, currentShell, publications } = yield* makeTestRelay({ - respond: (attempt) => - attempt <= 2 - ? new Response("relay unavailable", { status: 503 }) - : Response.json({ ok: true, deliveries: [] }), - }); - yield* Ref.set( - currentShell, - shell({ - status: "completed", - latestRunCompletedAt: DateTime.add(yield* DateTime.now, { seconds: 1 }), - }), - ); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.equal(publications.length, 1); - yield* TestClock.adjust("1 second"); - yield* relay.drain; - assert.equal(publications.length, 2); - yield* TestClock.adjust("2 seconds"); - yield* relay.drain; - assert.equal(publications.length, 3); - assert.isTrue(publications.every((publication) => publication.state?.phase === "completed")); - yield* TestClock.adjust("1 minute"); - yield* relay.drain; - assert.equal(publications.length, 3); - }), - ); - - it.effect("does not reset an exhausted retry budget for old confirmation timers", () => - Effect.gen(function* () { - let unavailable = false; - const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({ - respond: () => - unavailable - ? new Response("relay unavailable", { status: 503 }) - : Response.json({ ok: true, deliveries: [] }), - }); - yield* relay.publishThread(THREAD_ID); - for (let i = 0; i < 6; i++) { - yield* Ref.set(currentShell, null); - yield* relay.publishThread(THREAD_ID); - yield* Ref.set(currentShell, shell()); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust(1); - } - unavailable = true; - yield* Ref.set(currentShell, shell({ status: "failed" })); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust(1_000); - yield* relay.drain; - yield* TestClock.adjust(2_000); - yield* relay.drain; - yield* TestClock.adjust(1_994); - yield* relay.drain; - for (let i = 0; i < 5; i++) { - yield* TestClock.adjust(1); + assert.equal(publications.length, 2); + yield* TestClock.adjust("5 seconds"); yield* relay.drain; - } - const attempts = publications.length; - const reads = shellReads.length; - yield* TestClock.adjust("1 minute"); - yield* relay.drain; - assert.equal(publications.length, attempts); - assert.equal(shellReads.length, reads); - }), + assert.equal(publications.length, 3); + assert.equal(publications[2]?.state, null); + }).pipe(Effect.scoped), ); - it.effect("confirms tombstones and cancels stale confirmations when activity recovers", () => + it.effect("keeps settled live work until its monitor finishes", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); yield* relay.publishThread(THREAD_ID); - yield* Ref.set(currentShell, null); - yield* relay.publishThread(THREAD_ID); - assert.equal(publications.length, 1); - yield* Ref.set(currentShell, shell()); + const held = shell({ + status: "completed", + settledOverride: "settled", + pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor" }], + }); + yield* Ref.set(currentShell, held); yield* relay.publishThread(THREAD_ID); yield* TestClock.adjust("5 seconds"); yield* relay.drain; assert.equal(publications.length, 1); - yield* Ref.set(currentShell, null); + assert.equal(publications[0]?.state?.phase, "running"); + + yield* Ref.set(currentShell, { ...held, pendingBackgroundTasks: [] }); yield* relay.publishThread(THREAD_ID); - assert.equal(publications.length, 1); yield* TestClock.adjust("5 seconds"); yield* relay.drain; assert.equal(publications.length, 2); assert.equal(publications[1]?.state, null); }), ); - - it.effect.each([ - { label: "live", archived: false }, - { label: "archived", archived: true }, - ])("never publishes tombstones for $label subagent threads", ({ archived }) => - Effect.gen(function* () { - const { relay, currentShell, publications } = yield* makeTestRelay(); - yield* Ref.set( - currentShell, - shell({ - lineage: { - rootThreadId: THREAD_ID, - parentThreadId: THREAD_ID, - relationshipToParent: "subagent", - }, - ...(archived ? { archivedAt: yield* DateTime.now } : {}), - }), - ); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.equal(publications.length, 0); - }), - ); - - it.effect("confirms a first completed state and respects disabling during confirmation", () => - Effect.gen(function* () { - const { relay, secrets, currentShell, publications } = yield* makeTestRelay(); - yield* Ref.set( - currentShell, - shell({ - status: "completed", - latestRunCompletedAt: DateTime.add(yield* DateTime.now, { seconds: 1 }), - }), - ); - yield* relay.publishThread(THREAD_ID); - assert.equal(publications.length, 0); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.equal(publications[0]?.state?.phase, "completed"); - yield* Ref.set(currentShell, null); - yield* relay.publishThread(THREAD_ID); - yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("false")); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.equal(publications.length, 1); - }), - ); it.effect("publishes a held completion when its background item ends, not on tool output", () => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; @@ -928,69 +600,3 @@ describe("AgentAwarenessRelay", () => { }).pipe(Effect.scoped, Effect.provide(ProjectionStore.layerMemory)), ); }); - -describe("startup catch-up", { concurrent: false }, () => { - const link = (secrets: ServerSecretStore.ServerSecretStore["Service"]) => - Effect.all( - [ - secrets.set(RELAY_URL_SECRET, new TextEncoder().encode("https://relay.example.test")), - secrets.set(RELAY_ISSUER_SECRET, new TextEncoder().encode("https://relay.example.test")), - secrets.set(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, new TextEncoder().encode("credential-1")), - ], - { discard: true }, - ); - const enablePublishing = (secrets: ServerSecretStore.ServerSecretStore["Service"]) => - secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("true")); - const linkChecks = (secretReads: ReadonlyArray) => - secretReads.filter((name) => name === RELAY_URL_SECRET).length; - - it.effect("checks an unlinked environment once a minute and still catches up once linked", () => - Effect.gen(function* () { - const { relay, secrets, secretReads, catchUp } = yield* makeTestRelay({ linked: false }); - yield* enablePublishing(secrets); - yield* relay.start(); - - yield* TestClock.adjust("10 minutes"); - const checksBeforeWindow = linkChecks(secretReads); - yield* TestClock.adjust("10 minutes"); - assert.equal(linkChecks(secretReads) - checksBeforeWindow, 10); - assert.equal(catchUp.shellSnapshotReads, 0); - - yield* link(secrets); - yield* TestClock.adjust("1 minute"); - assert.equal(catchUp.shellSnapshotReads, 1); - }).pipe(Effect.scoped), - ); - - it.effect("publishes at once when this process links while the check is backed off", () => - Effect.gen(function* () { - const { relay, secrets, catchUp } = yield* makeTestRelay({ linked: false }); - yield* enablePublishing(secrets); - yield* relay.start(); - - yield* TestClock.adjust("10 minutes"); - yield* link(secrets); - yield* TestClock.adjust("1 second"); - assert.equal(catchUp.shellSnapshotReads, 0); - - yield* relay.requestCatchUp(); - yield* TestClock.adjust("1 second"); - assert.equal(catchUp.shellSnapshotReads, 1); - }).pipe(Effect.scoped), - ); - - it.effect("catches up within 5 s when another process enables publishing on a link", () => - Effect.gen(function* () { - const { relay, secrets, catchUp } = yield* makeTestRelay({ linked: false }); - yield* link(secrets); - yield* relay.start(); - - yield* TestClock.adjust("10 minutes"); - assert.equal(catchUp.shellSnapshotReads, 0); - - yield* enablePublishing(secrets); - yield* TestClock.adjust("5 seconds"); - assert.equal(catchUp.shellSnapshotReads, 1); - }).pipe(Effect.scoped), - ); -}); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index fb21aa6e70dd..c6a61817f69b 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -73,7 +73,7 @@ function isTurnItemPayload( ); } -export function shouldPublishAgentAwarenessEvent( +function shouldPublishAgentAwarenessEvent( event: Pick & { readonly payload?: unknown }, ): boolean { if ( @@ -93,6 +93,7 @@ export function shouldPublishAgentAwarenessEvent( case "thread.metadata-updated": case "thread.pull-request-link-updated": case "thread.pull-request-synced": + case "thread.settled": case "thread.model-selection-updated": case "thread.provider-switched": case "run.created": @@ -101,7 +102,7 @@ export function shouldPublishAgentAwarenessEvent( case "subagent.updated": case "provider-thread.updated": return true; - case "thread.settled": + // Unsettling must not re-alert a dismissed result. case "thread.unsettled": case "thread.snoozed": case "thread.unsnoozed": @@ -462,54 +463,53 @@ export const make = Effect.gen(function* () { } const environmentId = yield* serverEnvironment.getEnvironmentId; - const publishState = (input: { + const publishState = Effect.fn("relay.agent_activity.publish")(function* (input: { readonly projectId: string | null; readonly state: RelayAgentActivityState | null; readonly reason: string; - }) => - Effect.gen(function* () { - if (cachedRelayClient === undefined) { - cachedRelayClient = yield* makeRelayClient(relayConfig); - } - const relayClient = cachedRelayClient; - const proof = yield* makePublishProof({ - privateKey: cloudLinkKeyPair.privateKey, - relayIssuer: relayConfig.issuer, - environmentId, - threadId, - state: input.state, - jti: yield* crypto.randomUUIDv4(), - }); + }) { + if (cachedRelayClient === undefined) { + cachedRelayClient = yield* makeRelayClient(relayConfig); + } + const relayClient = cachedRelayClient; + const proof = yield* makePublishProof({ + privateKey: cloudLinkKeyPair.privateKey, + relayIssuer: relayConfig.issuer, + environmentId, + threadId, + state: input.state, + jti: yield* crypto.randomUUIDv4(), + }); - yield* Effect.logInfo("publishing agent activity for thread", { - environmentId, - threadId, - projectId: input.projectId, - statePhase: input.state?.phase ?? null, - hasState: input.state !== null, - reason: input.reason, - }); + yield* Effect.logInfo("publishing agent activity for thread", { + environmentId, + threadId, + projectId: input.projectId, + statePhase: input.state?.phase ?? null, + hasState: input.state !== null, + reason: input.reason, + }); - const response = yield* relayClient.server - .publishAgentActivity({ - params: { - environmentId, - threadId, - }, - payload: { - state: input.state, - proof, - }, - }) - .pipe(Effect.timeout("10 seconds")); - - yield* Effect.logInfo("agent activity publish completed", { - environmentId, - threadId, - ok: response.ok, - deliveries: deliveryStats(response.deliveries), - }); - }).pipe(Effect.withSpan("relay.agent_activity.publish"), withRelayClientTracing); + const response = yield* relayClient.server + .publishAgentActivity({ + params: { + environmentId, + threadId, + }, + payload: { + state: input.state, + proof, + }, + }) + .pipe(Effect.timeout("10 seconds")); + + yield* Effect.logInfo("agent activity publish completed", { + environmentId, + threadId, + ok: response.ok, + deliveries: deliveryStats(response.deliveries), + }); + }); // Per-thread shell read: this publish runs for every activity-relevant // domain event, so materializing the full shell here would make the cost @@ -614,7 +614,7 @@ export const make = Effect.gen(function* () { projectId: snapshot.projectId, state: snapshot.state, reason: snapshot.reason, - }); + }).pipe(withRelayClientTracing); publishConfirmDeadlines.delete(threadId); yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { const nextPublishedStates = new Map(publishedStates); @@ -729,31 +729,33 @@ export const make = Effect.gen(function* () { // Many environments never link, so while unlinked the retry backs off from 5 // s to 60 s. A linked environment keeps the 5 s retry, because `t3 connect - // publish` can turn publishing on from another process. - const publishActiveThreadsOnceWhenConfigured = (logEnabledWhenReady: boolean) => - Effect.gen(function* () { - let unlinkedRetryDelayMs = 5_000; - while (!(yield* Ref.get(activeSnapshotPublishedRef))) { - const result = yield* publishActiveThreadsUnsafe.pipe( - Effect.orElseSucceed(() => "failed" as const), - ); - if (result === "published") { - yield* Ref.set(activeSnapshotPublishedRef, true); - if (logEnabledWhenReady) { - const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); - yield* Effect.logInfo("agent activity publishing enabled after link reconciliation", { - relayUrl: relayConfig?.url, - }); - } - return; - } - const retryDelayMs = result === "unlinked" ? unlinkedRetryDelayMs : 5_000; - yield* Effect.race(Effect.sleep(retryDelayMs), Queue.take(catchUpRequests)); - if (result === "unlinked") { - unlinkedRetryDelayMs = Math.min(unlinkedRetryDelayMs * 2, 60_000); + // publish` can turn publishing on from another process. Keep this untraced: + // an unlinked environment can retry for the lifetime of the process. + const publishActiveThreadsOnceWhenConfigured = Effect.fnUntraced(function* ( + logEnabledWhenReady: boolean, + ) { + let unlinkedRetryDelayMs = 5_000; + while (!(yield* Ref.get(activeSnapshotPublishedRef))) { + const result = yield* publishActiveThreadsUnsafe.pipe( + Effect.orElseSucceed(() => "failed" as const), + ); + if (result === "published") { + yield* Ref.set(activeSnapshotPublishedRef, true); + if (logEnabledWhenReady) { + const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); + yield* Effect.logInfo("agent activity publishing enabled after link reconciliation", { + relayUrl: relayConfig?.url, + }); } + return; } - }); + const retryDelayMs = result === "unlinked" ? unlinkedRetryDelayMs : 5_000; + yield* Effect.race(Effect.sleep(retryDelayMs), Queue.take(catchUpRequests)); + if (result === "unlinked") { + unlinkedRetryDelayMs = Math.min(unlinkedRetryDelayMs * 2, 60_000); + } + } + }); schedulePublishConfirm = (threadId) => Effect.forkIn( diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts index 50c8ea2b7632..616643c6e1e9 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts @@ -1,94 +1,12 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; import { it } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as PubSub from "effect/PubSub"; -import * as Ref from "effect/Ref"; -import { assert, describe, expect } from "vite-plus/test"; +import { describe, expect } from "vite-plus/test"; -import { - type DesktopTelemetryReceiverHealth, - initialDesktopTelemetryContactAt, - isDesktopTelemetryContactStale, - recordDesktopTelemetrySampleHealth, - requireDesktopTelemetryWriteProgress, - resolveDesktopTelemetrySnapshotStaleAfterMs, - writeAllToFileDescriptor, -} from "./DesktopTelemetryReceiver.ts"; +import { requireDesktopTelemetryWriteProgress } from "./DesktopTelemetryReceiver.ts"; describe("DesktopTelemetryReceiver", () => { - it("degrades a hello-only stream after the first-sample deadline", () => { - expect(isDesktopTelemetryContactStale(Option.some(1_000), 90_999)).toBe(false); - expect(isDesktopTelemetryContactStale(Option.some(1_000), 91_000)).toBe(true); - expect(isDesktopTelemetryContactStale(Option.none(), 1_000_000)).toBe(false); - }); - - it("starts the stale deadline as soon as a telemetry descriptor is opened", () => { - expect(initialDesktopTelemetryContactAt(7, 1_000)).toEqual(Option.some(1_000)); - expect(initialDesktopTelemetryContactAt(undefined, 1_000)).toEqual(Option.none()); - }); - - it("keeps the snapshot deadline beyond the configured idle polling interval", () => { - expect(resolveDesktopTelemetrySnapshotStaleAfterMs(30_000, 120_000)).toBe(150_000); - expect(resolveDesktopTelemetrySnapshotStaleAfterMs(60_000, 600_000)).toBe(630_000); - expect(resolveDesktopTelemetrySnapshotStaleAfterMs(1_000, 1_000)).toBe(90_000); - }); - - it.effect("publishes the latest sample timestamp while health remains healthy", () => - Effect.scoped( - Effect.gen(function* () { - const initialSample = DateTime.makeUnsafe(1_000); - const nextSample = DateTime.makeUnsafe(2_000); - const health = yield* Ref.make({ - status: "healthy", - lastSampleAt: Option.some(initialSample), - lastError: Option.none(), - }); - const healthChanges = yield* PubSub.sliding(4); - const subscription = yield* PubSub.subscribe(healthChanges); - - yield* recordDesktopTelemetrySampleHealth(health, healthChanges, nextSample); - const published = yield* PubSub.take(subscription).pipe(Effect.timeout("1 second")); - - expect(DateTime.toEpochMillis(Option.getOrThrow(published.lastSampleAt))).toBe(2_000); - }), - ), - ); - - it.effect("writes control messages through the asynchronous descriptor path", () => - Effect.acquireUseRelease( - Effect.sync(() => { - const directory = NodeFS.mkdtempSync( - NodePath.join(NodeOS.tmpdir(), "t3-desktop-telemetry-control-test-"), - ); - const path = NodePath.join(directory, "control.ndjson"); - return { - directory, - path, - fd: NodeFS.openSync(path, "w"), - }; - }), - ({ fd, path }) => - Effect.gen(function* () { - const payload = Buffer.from('{"type":"setDiagnosticsDemand","enabled":true}\n'); - yield* writeAllToFileDescriptor(fd, payload); - NodeFS.fsyncSync(fd); - - assert.equal(NodeFS.readFileSync(path, "utf8"), payload.toString("utf8")); - }), - ({ directory, fd }) => - Effect.sync(() => { - NodeFS.closeSync(fd); - NodeFS.rmSync(directory, { recursive: true, force: true }); - }), - ), - ); - it.effect("models a zero-byte control write as a stalled descriptor", () => Effect.gen(function* () { const error = yield* requireDesktopTelemetryWriteProgress(7, 42, 0).pipe(Effect.flip); diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index a911c2b05d3d..ddd31817c4ce 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -202,7 +202,7 @@ const isProtocolMismatch = Schema.is(DesktopTelemetryProtocolMismatch); const isDecodeFailed = Schema.is(DesktopTelemetryDecodeFailed); const isStreamFailed = Schema.is(DesktopTelemetryStreamFailed); -export function isDesktopTelemetryContactStale( +function isDesktopTelemetryContactStale( lastContactAtMs: Option.Option, nowMs: number, ): boolean { @@ -212,7 +212,7 @@ export function isDesktopTelemetryContactStale( ); } -export function resolveDesktopTelemetrySnapshotStaleAfterMs( +function resolveDesktopTelemetrySnapshotStaleAfterMs( activeIntervalMs: number, idleIntervalMs: number, ): number { @@ -222,14 +222,14 @@ export function resolveDesktopTelemetrySnapshotStaleAfterMs( ); } -export function initialDesktopTelemetryContactAt( +function initialDesktopTelemetryContactAt( desktopTelemetryFd: number | undefined, nowMs: number, ): Option.Option { return desktopTelemetryFd === undefined ? Option.none() : Option.some(nowMs); } -export const recordDesktopTelemetrySampleHealth = Effect.fn( +const recordDesktopTelemetrySampleHealth = Effect.fn( "resourceTelemetry.desktopTelemetryReceiver.recordSampleHealth", )(function* ( health: Ref.Ref, diff --git a/apps/server/src/resourceTelemetry/Model.test.ts b/apps/server/src/resourceTelemetry/Model.test.ts index 0b855eaef5ff..25c119ed47f9 100644 --- a/apps/server/src/resourceTelemetry/Model.test.ts +++ b/apps/server/src/resourceTelemetry/Model.test.ts @@ -447,45 +447,6 @@ describe("resource telemetry process model", () => { expect(second.groups.backend.ioWriteBytes).toBe(3_000); }); - it("derives deltas at the constrained 15-second sampling cadence", () => { - const first = merge({ - native: nativeSnapshot(BASE_TIME_MS, [ - processSample({ - pid: SERVER_PID, - ppid: 1, - startTimeMs: 1_000, - cpuTimeMs: 1_000, - ioReadBytes: 10_000, - ioWriteBytes: 20_000, - }), - ]), - }); - const second = merge({ - previous: first, - native: nativeSnapshot( - BASE_TIME_MS + 15_000, - [ - processSample({ - pid: SERVER_PID, - ppid: 1, - startTimeMs: 1_000, - cpuTimeMs: 2_500, - ioReadBytes: 25_000, - ioWriteBytes: 50_000, - }), - ], - 2, - ), - }); - - expect(second.processes[0]?.cpuPercent).toBe(10); - expect(second.processes[0]?.ioReadBytesPerSecond).toBe(1_000); - expect(second.processes[0]?.ioWriteBytesPerSecond).toBe(2_000); - expect(second.groups.backend.cpuTimeMs).toBe(1_500); - expect(second.groups.backend.ioReadBytes).toBe(15_000); - expect(second.groups.backend.ioWriteBytes).toBe(30_000); - }); - it("preserves native rates while applying a desktop-only update", () => { const first = merge({ native: nativeSnapshot(BASE_TIME_MS, [ diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts index 44bb9c7c2002..46af99cb9dfd 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts @@ -1,36 +1,14 @@ -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import type { HostPowerSnapshot } from "@t3tools/contracts"; -import { - RESOURCE_MONITOR_PROTOCOL_VERSION, - ResourceMonitorCommand, - type ResourceMonitorHelloEvent, -} from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import { ChildProcessSpawner } from "effect/process"; -import * as ServerConfig from "../config.ts"; -import * as NativeTelemetryClient from "./NativeTelemetryClient.ts"; -import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; import { - canCommandNativeTelemetrySidecar, - canRequestNativeTelemetryRetry, commitCollectionControlUpdate, - retainRecentNativeTelemetryFailures, - resolveNativeSampleIntervalMs, synchronizeCollectionControlOnStart, } from "./NativeTelemetryClient.ts"; @@ -47,142 +25,6 @@ const basePower: HostPowerSnapshot = { updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"), }; -describe("resolveNativeSampleIntervalMs", () => { - it("keeps a recovery cadence while suspended and backs off under host constraints", () => { - expect(resolveNativeSampleIntervalMs({ ...basePower, suspended: true }, 1)).toBe(15_000); - expect(resolveNativeSampleIntervalMs({ ...basePower, locked: "true" }, 1)).toBe(15_000); - expect(resolveNativeSampleIntervalMs({ ...basePower, lowPowerMode: "true" }, 1)).toBe(15_000); - expect(resolveNativeSampleIntervalMs({ ...basePower, thermalState: "critical" }, 1)).toBe( - 15_000, - ); - expect(resolveNativeSampleIntervalMs({ ...basePower, onBattery: "true" }, 1)).toBe(5_000); - }); - - it("stops timed sampling without a live subscriber and serves live diagnostics at 1Hz", () => { - const unknown: HostPowerSnapshot = { - ...basePower, - source: "unknown", - stale: true, - }; - expect(resolveNativeSampleIntervalMs(unknown, 0)).toBe(0); - expect(resolveNativeSampleIntervalMs(unknown, 1)).toBe(1_000); - expect(resolveNativeSampleIntervalMs({ ...basePower, locked: "true" }, 0)).toBe(0); - expect(resolveNativeSampleIntervalMs({ ...basePower, onBattery: "true" }, 0)).toBe(0); - expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(0); - expect(resolveNativeSampleIntervalMs(basePower, 1)).toBe(1_000); - }); -}); - -const decodeCommandLine = Schema.decodeUnknownSync(Schema.fromJsonString(ResourceMonitorCommand)); - -const hello: ResourceMonitorHelloEvent = { - version: RESOURCE_MONITOR_PROTOCOL_VERSION, - type: "hello", - sidecarVersion: "test", - sidecarPid: 4242, - platform: "linux", - arch: "x64", - capabilities: { - cumulativeCpuTime: true, - currentCpuPercent: true, - residentMemory: true, - virtualMemory: true, - ioBytes: true, - processStartTime: true, - processTree: true, - }, -}; - -describe("NativeTelemetryClient", () => { - it.effect("asks the sidecar for timed samples only while a live subscriber exists", () => - Effect.gen(function* () { - const commands = yield* Queue.unbounded(); - const spawner = ChildProcessSpawner.make(() => - Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(hello.sidecarPid), - exitCode: Effect.never, - isRunning: Effect.succeed(true), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.forEach((chunk: Uint8Array) => - Effect.forEach(new TextDecoder().decode(chunk).split("\n").filter(Boolean), (line) => - Queue.offer(commands, decodeCommandLine(line)), - ), - ), - stdout: Stream.encodeText(Stream.make(`${JSON.stringify(hello)}\n`)).pipe( - Stream.concat(Stream.never), - ), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ), - ); - const client = yield* NativeTelemetryClient.make().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(ResourceMonitorBinary.ResourceMonitorBinary, { - resolve: Effect.succeed("/test/t3-resource-monitor"), - }), - ); - - const configure = yield* Queue.take(commands); - expect(configure).toMatchObject({ type: "configure", sampleIntervalMs: 0 }); - expect(yield* Queue.take(commands)).toMatchObject({ type: "setExternalProcesses" }); - - const live = yield* Scope.make(); - yield* client.snapshots.pipe(Stream.runDrain, Effect.forkIn(live)); - expect(yield* Queue.take(commands)).toMatchObject({ - type: "setSampleInterval", - sampleIntervalMs: 1_000, - }); - expect(yield* Queue.take(commands)).toMatchObject({ type: "setStreaming", enabled: true }); - - yield* Scope.close(live, Exit.void); - expect(yield* Queue.take(commands)).toMatchObject({ - type: "setSampleInterval", - sampleIntervalMs: 0, - }); - expect(yield* Queue.take(commands)).toMatchObject({ type: "setStreaming", enabled: false }); - expect((yield* client.health).sampleIntervalMs).toBe(0); - }).pipe( - Effect.provide( - Layer.mergeAll( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-native-telemetry-test-" }), - NodeCrypto.layer, - ).pipe(Layer.provideMerge(NodeServices.layer)), - ), - ), - ); -}); - -describe("canRequestNativeTelemetryRetry", () => { - it("only accepts retry while the supervisor is waiting without a live sidecar", () => { - expect(canRequestNativeTelemetryRetry("degraded", false)).toBe(true); - expect(canRequestNativeTelemetryRetry("unavailable", false)).toBe(true); - expect(canRequestNativeTelemetryRetry("degraded", true)).toBe(false); - expect(canRequestNativeTelemetryRetry("healthy", false)).toBe(false); - expect(canRequestNativeTelemetryRetry("starting", false)).toBe(false); - }); -}); - -describe("canCommandNativeTelemetrySidecar", () => { - it("keeps on-demand recovery commands available while a live sidecar is degraded", () => { - expect(canCommandNativeTelemetrySidecar("healthy", true)).toBe(true); - expect(canCommandNativeTelemetrySidecar("degraded", true)).toBe(true); - expect(canCommandNativeTelemetrySidecar("unavailable", true)).toBe(false); - expect(canCommandNativeTelemetrySidecar("degraded", false)).toBe(false); - }); -}); - -describe("retainRecentNativeTelemetryFailures", () => { - it("expires old failures so an isolated crash restarts from the initial backoff", () => { - expect(retainRecentNativeTelemetryFailures([0, 30_000], 90_001)).toEqual([]); - expect(retainRecentNativeTelemetryFailures([30_000, 60_000], 90_000)).toEqual([30_000, 60_000]); - }); -}); - describe("commitCollectionControlUpdate", () => { it.effect("retains desired demand and retries unapplied sidecar state", () => Effect.gen(function* () { diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index 9d252fadb4f7..6f4a019a8ac7 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -261,7 +261,7 @@ function isThermallyConstrained(snapshot: HostPowerSnapshot): boolean { // Without a live subscriber the sidecar does not sample on a timer, so an idle server // never walks the process table. History then covers only time with a subscriber. -export function resolveNativeSampleIntervalMs( +function resolveNativeSampleIntervalMs( snapshot: HostPowerSnapshot, liveSubscriberCount: number, ): number { @@ -279,23 +279,22 @@ export function resolveNativeSampleIntervalMs( return SAMPLE_INTERVAL_MS; } -export function commitCollectionControlUpdate( +// Desktop power samples also commit control state; avoid a span for each sample. +export const commitCollectionControlUpdate = Effect.fnUntraced(function* ( desiredState: Ref.Ref, appliedState: Ref.Ref, update: (current: CollectionControl) => CollectionControl, apply: (previous: CollectionControl, next: CollectionControl) => Effect.Effect, -): Effect.Effect { - return Effect.gen(function* () { - const [previousDesired, next] = yield* Ref.modify(desiredState, (previous) => { - const next = update(previous); - return [[previous, next] as const, next]; - }); - const previousApplied = yield* Ref.get(appliedState); - yield* apply(previousApplied, next); - yield* Ref.set(appliedState, next); - return [previousDesired, next] as const; +): Effect.fn.Return { + const [previousDesired, next] = yield* Ref.modify(desiredState, (previous) => { + const next = update(previous); + return [[previous, next] as const, next]; }); -} + const previousApplied = yield* Ref.get(appliedState); + yield* apply(previousApplied, next); + yield* Ref.set(appliedState, next); + return [previousDesired, next] as const; +}); export function synchronizeCollectionControlOnStart( mutex: Semaphore.Semaphore, @@ -337,7 +336,7 @@ function restartDelay(attempt: number): Duration.Duration { return Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY); } -export function retainRecentNativeTelemetryFailures( +function retainRecentNativeTelemetryFailures( failures: ReadonlyArray, now: number, ): ReadonlyArray { @@ -348,14 +347,14 @@ function errorMessage(error: NativeTelemetryClientError): string { return error.message; } -export function canRequestNativeTelemetryRetry( +function canRequestNativeTelemetryRetry( status: ResourceTelemetrySourceStatus, hasHandle: boolean, ): boolean { return status !== "healthy" && status !== "starting" && !hasHandle; } -export function canCommandNativeTelemetrySidecar( +function canCommandNativeTelemetrySidecar( status: ResourceTelemetrySourceStatus, hasHandle: boolean, ): boolean { @@ -411,23 +410,22 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu Effect.asVoid, ); - const failPending = (error: NativeTelemetryClientError) => - Effect.gen(function* () { - const samples = yield* Ref.getAndSet(pendingSamples, new Map()); - const processTables = yield* Ref.getAndSet(pendingProcessTables, new Map()); - const histories = yield* Ref.getAndSet(pendingHistories, new Map()); - yield* Effect.forEach(samples.values(), (deferred) => Deferred.fail(deferred, error), { - discard: true, - }); - yield* Effect.forEach(processTables.values(), (deferred) => Deferred.fail(deferred, error), { - discard: true, - }); - yield* Effect.forEach( - histories.values(), - (request) => Deferred.fail(request.deferred, error), - { discard: true }, - ); + const failPending = Effect.fn("NativeTelemetryClient.failPending")(function* ( + error: NativeTelemetryClientError, + ) { + const samples = yield* Ref.getAndSet(pendingSamples, new Map()); + const processTables = yield* Ref.getAndSet(pendingProcessTables, new Map()); + const histories = yield* Ref.getAndSet(pendingHistories, new Map()); + yield* Effect.forEach(samples.values(), (deferred) => Deferred.fail(deferred, error), { + discard: true, + }); + yield* Effect.forEach(processTables.values(), (deferred) => Deferred.fail(deferred, error), { + discard: true, + }); + yield* Effect.forEach(histories.values(), (request) => Deferred.fail(request.deferred, error), { + discard: true, }); + }); const writeCommand = ( handle: ChildProcessSpawner.ChildProcessHandle, @@ -777,9 +775,11 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu Effect.forkScoped, ); - const applyCollectionControl = Effect.fn( - "resourceTelemetry.nativeTelemetryClient.applyCollectionControl", - )(function* (previous: CollectionControl, next: CollectionControl) { + // Desktop power samples reach this even when the control state has not changed. + const applyCollectionControl = Effect.fnUntraced(function* ( + previous: CollectionControl, + next: CollectionControl, + ) { const current = yield* Ref.get(state); if (canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) { const handle = Option.getOrThrow(current.handle); @@ -842,10 +842,9 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu }), ); - const setExternalProcesses: NativeTelemetryClient["Service"]["setExternalProcesses"] = ( - processes, - ) => - Effect.gen(function* () { + // Desktop telemetry refreshes this process set on every sample. + const setExternalProcesses: NativeTelemetryClient["Service"]["setExternalProcesses"] = + Effect.fnUntraced(function* (processes) { yield* Ref.set(externalProcesses, [...processes]); const current = yield* Ref.get(state); if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) return; @@ -856,64 +855,65 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu }); }); - const readHistory: NativeTelemetryClient["Service"]["readHistory"] = (windowMs) => - Effect.gen(function* () { - const current = yield* Ref.get(state); - if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) { - return yield* new NativeTelemetryUnavailable({ - reason: Option.getOrElse(current.lastError, () => "sidecar is not running"), - }); - } - const requestId = yield* crypto.randomUUIDv4().pipe( - Effect.mapError( - (cause) => - new NativeTelemetryCommandFailed({ - operation: "createHistoryRequestId", - cause, - }), - ), - ); - const deferred = yield* Deferred.make< - ReadonlyArray, - NativeTelemetryClientError - >(); - yield* Ref.update(pendingHistories, (pending) => { - const next = new Map(pending); - next.set(requestId, { deferred, snapshots: [] }); - return next; + const readHistory: NativeTelemetryClient["Service"]["readHistory"] = Effect.fn( + "NativeTelemetryClient.readHistory", + )(function* (windowMs) { + const current = yield* Ref.get(state); + if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) { + return yield* new NativeTelemetryUnavailable({ + reason: Option.getOrElse(current.lastError, () => "sidecar is not running"), }); - return yield* writeCommand(Option.getOrThrow(current.handle), { - version: RESOURCE_MONITOR_PROTOCOL_VERSION, - type: "readHistory", - requestId, - windowMs: Math.max(0, Math.round(windowMs)), - }).pipe( - Effect.andThen( - Deferred.await(deferred).pipe( - Effect.timeoutOption(HISTORY_REQUEST_TIMEOUT), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new NativeTelemetryRequestTimedOut({ - operation: "readHistory", - timeoutMs: Duration.toMillis(HISTORY_REQUEST_TIMEOUT), - }), - ), - onSome: Effect.succeed, - }), - ), - ), - ), - Effect.ensuring( - Ref.update(pendingHistories, (pending) => { - const next = new Map(pending); - next.delete(requestId); - return next; + } + const requestId = yield* crypto.randomUUIDv4().pipe( + Effect.mapError( + (cause) => + new NativeTelemetryCommandFailed({ + operation: "createHistoryRequestId", + cause, }), - ), - ); + ), + ); + const deferred = yield* Deferred.make< + ReadonlyArray, + NativeTelemetryClientError + >(); + yield* Ref.update(pendingHistories, (pending) => { + const next = new Map(pending); + next.set(requestId, { deferred, snapshots: [] }); + return next; }); + return yield* writeCommand(Option.getOrThrow(current.handle), { + version: RESOURCE_MONITOR_PROTOCOL_VERSION, + type: "readHistory", + requestId, + windowMs: Math.max(0, Math.round(windowMs)), + }).pipe( + Effect.andThen( + Deferred.await(deferred).pipe( + Effect.timeoutOption(HISTORY_REQUEST_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new NativeTelemetryRequestTimedOut({ + operation: "readHistory", + timeoutMs: Duration.toMillis(HISTORY_REQUEST_TIMEOUT), + }), + ), + onSome: Effect.succeed, + }), + ), + ), + ), + Effect.ensuring( + Ref.update(pendingHistories, (pending) => { + const next = new Map(pending); + next.delete(requestId); + return next; + }), + ), + ); + }); const sampleNow: NativeTelemetryClient["Service"]["sampleNow"] = Effect.gen(function* () { const current = yield* Ref.get(state); diff --git a/apps/server/src/resourceTelemetry/ProcessGroupMetrics.test.ts b/apps/server/src/resourceTelemetry/ProcessGroupMetrics.test.ts new file mode 100644 index 000000000000..694803b64842 --- /dev/null +++ b/apps/server/src/resourceTelemetry/ProcessGroupMetrics.test.ts @@ -0,0 +1,59 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { ResourceTelemetryProcess } from "@t3tools/contracts"; + +import { groupProcesses } from "./ProcessGroupMetrics.ts"; + +const process = ( + pid: number, + ppid: number, + name: string, + category: ResourceTelemetryProcess["category"] = "server-child", + command = name, +) => + ({ + identity: { pid, startTimeMs: 1 }, + ppid, + name, + command, + category, + residentBytes: pid * 1_000, + cpuPercent: pid, + }) as ResourceTelemetryProcess; + +describe("groupProcesses", () => { + it("sums each direct child of the server with its whole subtree", () => { + const totals = groupProcesses([ + process(10, 1, "node", "server"), + process(11, 10, "t3-resource-mon", "resource-monitor"), + process(20, 10, "claude"), + process(21, 20, "bash"), + process(22, 21, "cargo"), + process(30, 10, "codex"), + process(40, 10, "zsh"), + process(50, 10, "chrome-headless"), + process(60, 10, "git", "server-child", "git checkout claude/fix"), + process(70, 99, "orphan"), + // An older Claude package runs as a script under node. + process( + 80, + 10, + "node", + "server-child", + "node /opt/node_modules/@anthropic-ai/claude-code/cli.js", + ), + process(90, 10, "node", "server-child", "node /srv/tools/report.js"), + ]); + + assert.deepStrictEqual(Object.fromEntries(totals), { + server: { residentBytes: 10_000, cpuPercent: 10, count: 1 }, + "resource-monitor": { residentBytes: 11_000, cpuPercent: 11, count: 1 }, + // The agent's shell and the build it ran belong to the agent, not the terminal group. + claude: { residentBytes: 143_000, cpuPercent: 143, count: 4 }, + codex: { residentBytes: 30_000, cpuPercent: 30, count: 1 }, + terminal: { residentBytes: 40_000, cpuPercent: 40, count: 1 }, + browser: { residentBytes: 50_000, cpuPercent: 50, count: 1 }, + // `git` whose arguments mention claude, a parentless process, and a node script. + other: { residentBytes: 220_000, cpuPercent: 220, count: 3 }, + }); + }); +}); diff --git a/apps/server/src/resourceTelemetry/ProcessGroupMetrics.ts b/apps/server/src/resourceTelemetry/ProcessGroupMetrics.ts new file mode 100644 index 000000000000..6f8f29c37161 --- /dev/null +++ b/apps/server/src/resourceTelemetry/ProcessGroupMetrics.ts @@ -0,0 +1,125 @@ +import type { ResourceTelemetryProcess } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Metric from "effect/Metric"; + +import * as ServerConfig from "../config.ts"; +import { metricAttributes } from "../observability/Metrics.ts"; +import * as ResourceTelemetry from "./ResourceTelemetry.ts"; + +const SAMPLE_INTERVAL = "1 minute"; + +const residentBytes = Metric.gauge("t3_process_group_resident_bytes", { + description: "Resident memory of the server's processes, summed by group.", + attributes: { unit: "By" }, +}); +const cpuPercent = Metric.gauge("t3_process_group_cpu_percent", { + description: "CPU use of the server's processes, summed by group; 100 is one full core.", + attributes: { unit: "%" }, +}); +const processCount = Metric.gauge("t3_process_group_count", { + description: "Processes in each group.", + attributes: { unit: "{process}" }, +}); + +// Each direct child of the server names the group of its whole subtree. A fixed set keeps one +// series per group per server, however many agents and processes come and go. +const PROVIDER_GROUPS: ReadonlyArray = [ + [/codex/, "codex"], + [/claude/, "claude"], + [/cursor/, "cursor"], + [/opencode/, "opencode"], + [/grok/, "grok"], + [/antigravity|gemini/, "antigravity"], +]; +const OTHER_GROUPS: ReadonlyArray = [ + [/chrom/, "browser"], + [/^(?:ba|z|fi|da|c)?sh(?:\.exe)?$|^pwsh|^powershell|^cmd\.exe$/, "terminal"], +]; +// A CLI shipped as a script runs under a JavaScript runtime, so its process name is the +// runtime's (older Claude packages run `node .../@anthropic-ai/claude-code/cli.js`). Only those +// are matched by command line; any other name is matched by name alone, so a shell or `git` +// whose arguments mention a provider stays in its own group. +const SCRIPT_RUNTIME = /^(?:node|bun|deno)(?:\.exe)?$/; + +const match = (groups: ReadonlyArray, text: string) => + groups.find(([pattern]) => pattern.test(text))?.[1]; + +const childGroup = (name: string, command: string) => { + const lowerName = name.toLowerCase(); + if (SCRIPT_RUNTIME.test(lowerName)) + return match(PROVIDER_GROUPS, command.toLowerCase()) ?? "other"; + return match(PROVIDER_GROUPS, lowerName) ?? match(OTHER_GROUPS, lowerName) ?? "other"; +}; + +export interface ProcessGroupTotals { + readonly residentBytes: number; + readonly cpuPercent: number; + readonly count: number; +} + +export function groupProcesses( + processes: ReadonlyArray, +): ReadonlyMap { + const byPid = new Map(processes.map((process) => [process.identity.pid, process])); + const serverPid = processes.find((process) => process.category === "server")?.identity.pid; + const groupOf = (process: ResourceTelemetryProcess): string => { + if (process.category === "server") return "server"; + if (process.category === "resource-monitor") return "resource-monitor"; + if (process.category.startsWith("electron-")) return "electron"; + let current = process; + // Bounded by the table size, in case a reused PID makes the parent chain loop. + for (let step = 0; step < processes.length; step++) { + if (current.ppid === serverPid) return childGroup(current.name, current.command); + const parent = byPid.get(current.ppid); + if (parent === undefined) break; + current = parent; + } + return "other"; + }; + const totals = new Map(); + for (const process of processes) { + const group = groupOf(process); + const total = totals.get(group) ?? { residentBytes: 0, cpuPercent: 0, count: 0 }; + totals.set(group, { + residentBytes: total.residentBytes + process.residentBytes, + cpuPercent: total.cpuPercent + process.cpuPercent, + count: total.count + 1, + }); + } + return totals; +} + +/** + * Exports process totals by group while OTLP metrics export is on. Sampling walks the process + * table, which an idle server otherwise never does, so only a server exporting metrics pays for + * it, once a minute. + */ +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + if (config.otlpMetricsUrl === undefined) return; + const telemetry = yield* ResourceTelemetry.ResourceTelemetry; + // A group that empties reads zero rather than holding its last value. + const seen = new Set(); + const sample = Effect.fn("ProcessGroupMetrics.sample")(function* () { + const totals = groupProcesses((yield* telemetry.refresh).processes); + for (const group of totals.keys()) seen.add(group); + for (const group of seen) { + const total = totals.get(group) ?? { residentBytes: 0, cpuPercent: 0, count: 0 }; + const attributes = metricAttributes({ group }); + yield* Metric.update(Metric.withAttributes(residentBytes, attributes), total.residentBytes); + yield* Metric.update(Metric.withAttributes(cpuPercent, attributes), total.cpuPercent); + yield* Metric.update(Metric.withAttributes(processCount, attributes), total.count); + } + }); + yield* sample().pipe( + Effect.catch((error) => + Effect.logDebug("process group metrics sample failed", { error: error.message }), + ), + Effect.andThen(Effect.sleep(SAMPLE_INTERVAL)), + Effect.forever, + Effect.forkScoped, + ); + }), +); diff --git a/apps/server/src/resourceTelemetry/ResourceAttribution.ts b/apps/server/src/resourceTelemetry/ResourceAttribution.ts index 374564bb7d88..a8943a35f32d 100644 --- a/apps/server/src/resourceTelemetry/ResourceAttribution.ts +++ b/apps/server/src/resourceTelemetry/ResourceAttribution.ts @@ -32,7 +32,7 @@ function nonNegativeInteger(value: number | undefined, fallback: number): number return Math.max(0, Math.round(value)); } -export const make = Effect.fn("resourceTelemetry.resourceAttribution.make")(function* () { +const make = Effect.fn("resourceTelemetry.resourceAttribution.make")(function* () { const entries = yield* Ref.make(new Map()); const record: ResourceAttribution["Service"]["record"] = (input) => diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts index 1f30be947718..c79b7f466f12 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { afterEach, assert, describe, expect, it, vi } from "@effect/vitest"; +import { afterEach, assert, describe, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -16,56 +16,6 @@ describe("ResourceMonitorBinary", () => { vi.restoreAllMocks(); }); - it.effect("skips Linux libc detection on Windows", () => - Effect.gen(function* () { - const getReport = vi.spyOn(process.report, "getReport").mockImplementation(() => { - throw new Error("Linux libc detection must not run on Windows"); - }); - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-resource-monitor-binary-", - }); - const binaryPath = `${baseDir}/t3-resource-monitor.exe`; - yield* fileSystem.writeFileString(binaryPath, "binary"); - - const service = yield* ResourceMonitorBinary.make().pipe( - Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), - Effect.provideService(HostProcess.Platform, "win32"), - Effect.provideService(HostProcess.Architecture, "arm64"), - Effect.provideService(HostProcess.Environment, { - T3CODE_RESOURCE_MONITOR_PATH: binaryPath, - }), - ); - - assert.equal(yield* service.resolve, binaryPath); - expect(getReport).not.toHaveBeenCalled(); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect.skipIf(windowsHost)("resolves an executable override", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-resource-monitor-binary-", - }); - const binaryPath = `${baseDir}/t3-resource-monitor`; - yield* fileSystem.writeFileString(binaryPath, "binary"); - yield* fileSystem.chmod(binaryPath, 0o755); - - const service = yield* ResourceMonitorBinary.make().pipe( - Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), - Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService(HostProcess.Architecture, "x64"), - Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "musl"), - Effect.provideService(HostProcess.Environment, { - T3CODE_RESOURCE_MONITOR_PATH: binaryPath, - }), - ); - - assert.equal(yield* service.resolve, binaryPath); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect.skipIf(windowsHost)("resolves an executable override on an unsupported platform", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -115,6 +65,7 @@ describe("ResourceMonitorBinary", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + // regression: #16838 a directory resolved as the resource monitor binary it.effect("rejects a directory override", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -137,24 +88,6 @@ describe("ResourceMonitorBinary", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("rejects unsupported platform and architecture pairs", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-resource-monitor-binary-", - }); - const service = yield* ResourceMonitorBinary.make().pipe( - Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), - Effect.provideService(HostProcess.Platform, "freebsd"), - Effect.provideService(HostProcess.Architecture, "ia32"), - Effect.provideService(HostProcess.Environment, {}), - ); - const error = yield* Effect.flip(service.resolve); - - assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryUnsupported); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("rejects bundled glibc binaries on musl Linux hosts", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts b/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts index c4273d7336ac..c12e2e44ed7e 100644 --- a/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts @@ -6,7 +6,6 @@ import type { import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -14,13 +13,20 @@ import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as DesktopTelemetryReceiver from "./DesktopTelemetryReceiver.ts"; import * as NativeTelemetryClient from "./NativeTelemetryClient.ts"; import * as ResourceAttribution from "./ResourceAttribution.ts"; import * as ResourceTelemetry from "./ResourceTelemetry.ts"; +// ResourceTelemetry classifies process.pid as the server, so fixture PIDs are +// offsets from it. Fixed PIDs collide whenever the test worker is assigned one. +const SERVER_PID = process.pid; +const CODEX_PID = SERVER_PID + 1; +const SHORT_LIVED_CHILD_PID = SERVER_PID + 2; +const ELECTRON_PID = SERVER_PID + 3; +const MONITOR_PID = SERVER_PID + 4; + function processSample( input: Partial & Pick, @@ -50,14 +56,14 @@ function nativeSnapshot(input: { }): ResourceMonitorSnapshotEvent { const processes = [ processSample({ - pid: process.pid, + pid: SERVER_PID, ppid: 1, startTimeMs: 100, cpuTimeMs: input.sequence * 10, }), processSample({ - pid: 4_242, - ppid: process.pid, + pid: CODEX_PID, + ppid: SERVER_PID, startTimeMs: 200, name: "codex", command: "codex app-server", @@ -65,7 +71,7 @@ function nativeSnapshot(input: { ioWriteBytes: input.childWriteBytes, }), processSample({ - pid: 5_000, + pid: ELECTRON_PID, ppid: 1, startTimeMs: 300, name: "electron", @@ -73,8 +79,8 @@ function nativeSnapshot(input: { cpuTimeMs: input.sequence * 20, }), processSample({ - pid: 9_000, - ppid: process.pid, + pid: MONITOR_PID, + ppid: SERVER_PID, startTimeMs: 400, name: "t3-resource-monitor", command: "t3-resource-monitor", @@ -111,7 +117,7 @@ function desktopSnapshot(sampledAtUnixMs: number): DesktopHostTelemetrySnapshot type: "desktopTelemetry", sequence: 1, sampledAtUnixMs, - electronPid: 5_000, + electronPid: ELECTRON_PID, power: { source: "electron-main", idle: "false", @@ -127,7 +133,7 @@ function desktopSnapshot(sampledAtUnixMs: number): DesktopHostTelemetrySnapshot speedLimitPercent: Option.some(90), electronProcesses: [ { - pid: 5_000, + pid: ELECTRON_PID, creationTimeMs: 300, type: "Browser", name: "electron", @@ -154,17 +160,17 @@ describe("ResourceTelemetry", () => { childWriteBytes: index * 100, }); const processes = sample.processes - .filter((entry) => entry.pid === process.pid || entry.pid === 4_242) + .filter((entry) => entry.pid === SERVER_PID || entry.pid === CODEX_PID) .map((entry) => - entry.pid === 4_242 + entry.pid === CODEX_PID ? { ...entry, startTimeMs: index } : { ...entry, ioWriteBytes: index * 100 }, ); if (index === 0 || index === 2) { processes.push( processSample({ - pid: 4_243, - ppid: process.pid, + pid: SHORT_LIVED_CHILD_PID, + ppid: SERVER_PID, startTimeMs: 500, cpuTimeMs: index === 0 ? 100 : 350, ioWriteBytes: index === 0 ? 1_000 : 5_000, @@ -192,18 +198,20 @@ describe("ResourceTelemetry", () => { expect(history.topProcesses).toHaveLength(sampleCount + 2); expect( - history.topProcesses.find((entry) => entry.identity.pid === process.pid), + history.topProcesses.find((entry) => entry.identity.pid === SERVER_PID), ).toMatchObject({ sampleCount, cpuTimeMs: (sampleCount - 1) * 10, ioWriteBytes: (sampleCount - 1) * 100, }); - const children = history.topProcesses.filter((entry) => entry.identity.pid === 4_242); + const children = history.topProcesses.filter((entry) => entry.identity.pid === CODEX_PID); expect(children).toHaveLength(sampleCount); for (const child of children) { expect(child).toMatchObject({ sampleCount: 1, cpuTimeMs: 0, ioWriteBytes: 0 }); } - expect(history.topProcesses.find((entry) => entry.identity.pid === 4_243)).toMatchObject({ + expect( + history.topProcesses.find((entry) => entry.identity.pid === SHORT_LIVED_CHILD_PID), + ).toMatchObject({ sampleCount: 2, cpuTimeMs: 250, ioWriteBytes: 4_000, @@ -216,87 +224,6 @@ describe("ResourceTelemetry", () => { }), ); - it.effect("enables live native and Electron collection only while changes are retained", () => - Effect.gen(function* () { - const sampledAtUnixMs = DateTime.toEpochMillis(yield* DateTime.now); - const sample = nativeSnapshot({ - sequence: 1, - sampledAtUnixMs, - childCpuTimeMs: 100, - childWriteBytes: 1_000, - }); - const demandChanges = yield* Ref.make>([]); - const layerNative = NativeTelemetryClient.layerTest({ - sampleNow: Effect.succeed(nativeGeneration(sample, 0)), - health: Effect.succeed({ - status: "healthy", - hello: Option.none(), - lastSampleAt: Option.none(), - lastError: Option.none(), - restartCount: 0, - sampleIntervalMs: 1_000, - }), - }); - const layerDesktop = DesktopTelemetryReceiver.layerTest({ - latest: Effect.succeedSome(desktopSnapshot(sampledAtUnixMs)), - setDiagnosticsDemand: (enabled) => - Ref.update(demandChanges, (changes) => [...changes, enabled]), - }); - const layerTelemetry = ResourceTelemetry.layer.pipe( - Layer.provide(Layer.mergeAll(layerNative, layerDesktop, ResourceAttribution.layer)), - ); - - const live = yield* Stream.runHead( - Effect.gen(function* () { - const telemetry = yield* ResourceTelemetry.ResourceTelemetry; - return telemetry.changes; - }).pipe(Stream.unwrap), - ).pipe(Effect.provide(layerTelemetry)); - - expect(Option.isSome(live)).toBe(true); - expect(yield* Ref.get(demandChanges)).toEqual([true, false]); - }), - ); - - it.effect("releases live demand when acquisition is interrupted by an idle collector", () => - Effect.gen(function* () { - const sampledAtUnixMs = DateTime.toEpochMillis(yield* DateTime.now); - const demandChanges = yield* Ref.make>([]); - const layerNative = NativeTelemetryClient.layerTest({ - sampleNow: Effect.never, - health: Effect.succeed({ - status: "healthy", - hello: Option.none(), - lastSampleAt: Option.none(), - lastError: Option.none(), - restartCount: 0, - sampleIntervalMs: 1_000, - }), - }); - const layerDesktop = DesktopTelemetryReceiver.layerTest({ - latest: Effect.succeedSome(desktopSnapshot(sampledAtUnixMs)), - setDiagnosticsDemand: (enabled) => - Ref.update(demandChanges, (changes) => [...changes, enabled]), - }); - const layerTelemetry = ResourceTelemetry.layer.pipe( - Layer.provide(Layer.mergeAll(layerNative, layerDesktop, ResourceAttribution.layer)), - ); - - const resultFiber = yield* Stream.runHead( - Effect.gen(function* () { - const telemetry = yield* ResourceTelemetry.ResourceTelemetry; - return telemetry.changes; - }).pipe(Stream.unwrap), - ).pipe(Effect.timeoutOption("10 millis"), Effect.provide(layerTelemetry), Effect.forkChild); - yield* Effect.yieldNow; - yield* TestClock.adjust("10 millis"); - const result = yield* Fiber.join(resultFiber); - - expect(Option.isNone(result)).toBe(true); - expect(yield* Ref.get(demandChanges)).toEqual([true, false]); - }), - ); - it.effect( "attributes an initial Electron root from the identity recorded by the native snapshot", () => @@ -307,7 +234,7 @@ describe("ResourceTelemetry", () => { sampledAtUnixMs, childCpuTimeMs: 100, childWriteBytes: 1_000, - externalProcesses: [{ pid: 5_000, startTimeMs: 300 }], + externalProcesses: [{ pid: ELECTRON_PID, startTimeMs: 300 }], }); const desktop = { ...desktopSnapshot(sampledAtUnixMs), @@ -336,9 +263,9 @@ describe("ResourceTelemetry", () => { return yield* telemetry.refresh; }).pipe(Effect.provide(layerTelemetry)); - expect(snapshot.processes.find((entry) => entry.identity.pid === 5_000)?.category).toBe( - "electron-main", - ); + expect( + snapshot.processes.find((entry) => entry.identity.pid === ELECTRON_PID)?.category, + ).toBe("electron-main"); expect(snapshot.groups.electron.processStarts).toBe(1); expect(snapshot.groups.backend.processStarts).toBe(3); }), @@ -405,73 +332,6 @@ describe("ResourceTelemetry", () => { ), ); - it.effect("retains desktop health changes while aggregate telemetry initializes", () => - Effect.scoped( - Effect.gen(function* () { - const health = yield* Ref.make({ - status: "starting", - lastSampleAt: Option.none(), - lastError: Option.none(), - }); - const healthChanges = - yield* PubSub.sliding(4); - const healthSubscribed = yield* Deferred.make(); - const finishDesktopSnapshot = yield* Deferred.make(); - const layerDesktop = DesktopTelemetryReceiver.layerTest({ - health: Ref.get(health), - subscribeHealth: Effect.gen(function* () { - const subscription = yield* PubSub.subscribe(healthChanges); - const latest = yield* Ref.get(health); - yield* Deferred.succeed(healthSubscribed, undefined); - return { - latest, - changes: Stream.fromSubscription(subscription), - }; - }), - subscribe: Deferred.await(finishDesktopSnapshot).pipe( - Effect.as({ - latest: Option.none(), - changes: Stream.empty, - }), - ), - }); - const layerTelemetry = ResourceTelemetry.layer.pipe( - Layer.provide( - Layer.mergeAll( - NativeTelemetryClient.layerTest(), - layerDesktop, - ResourceAttribution.layer, - ), - ), - ); - const resultFiber = yield* Effect.gen(function* () { - const telemetry = yield* ResourceTelemetry.ResourceTelemetry; - while (true) { - const snapshot = yield* telemetry.latest; - if (snapshot.health.desktop.status === "degraded") return snapshot; - yield* Effect.yieldNow; - } - }).pipe(Effect.provide(layerTelemetry), Effect.timeout("1 second"), Effect.forkChild); - - yield* Deferred.await(healthSubscribed); - const degraded: DesktopTelemetryReceiver.DesktopTelemetryReceiverHealth = { - status: "degraded", - lastSampleAt: Option.none(), - lastError: Option.some("desktop telemetry failed"), - }; - yield* Ref.set(health, degraded); - yield* PubSub.publish(healthChanges, degraded); - yield* Deferred.succeed(finishDesktopSnapshot, undefined); - - const snapshot = yield* Fiber.join(resultFiber); - expect(snapshot.health.desktop.status).toBe("degraded"); - expect(Option.getOrNull(snapshot.health.desktop.lastError)).toBe( - "desktop telemetry failed", - ); - }), - ), - ); - it.effect("atomically subscribes while a desktop update is being rebuilt", () => Effect.scoped( Effect.gen(function* () { @@ -539,183 +399,4 @@ describe("ResourceTelemetry", () => { }), ), ); - - it.effect("combines native, Electron, attribution, retry, and history data", () => - Effect.gen(function* () { - const startedAt = DateTime.toEpochMillis(yield* DateTime.now); - const samples = [ - nativeSnapshot({ - sequence: 1, - sampledAtUnixMs: startedAt, - childCpuTimeMs: 100, - childWriteBytes: 1_000, - }), - nativeSnapshot({ - sequence: 2, - sampledAtUnixMs: startedAt + 1_000, - childCpuTimeMs: 350, - childWriteBytes: 5_000, - }), - nativeSnapshot({ - sequence: 1, - sampledAtUnixMs: startedAt + 2_000, - childCpuTimeMs: 500, - childWriteBytes: 7_000, - }), - ] as const; - const sampleIndex = yield* Ref.make(0); - const externalProcesses = yield* Ref.make< - ReadonlyArray<{ readonly pid: number; readonly startTimeMs?: number }> - >([]); - const retryCount = yield* Ref.make(0); - const nativeHealth = yield* Ref.make({ - status: "healthy", - hello: Option.some({ - version: 3, - type: "hello", - sidecarVersion: "0.1.0", - sidecarPid: 9_000, - platform: "test", - arch: "test", - capabilities: { - cumulativeCpuTime: true, - currentCpuPercent: true, - residentMemory: true, - virtualMemory: true, - ioBytes: true, - processStartTime: true, - processTree: true, - }, - }), - lastSampleAt: Option.some(DateTime.makeUnsafe(startedAt)), - lastError: Option.none(), - restartCount: 2, - sampleIntervalMs: 1_000, - }); - const nativeHealthChanges = - yield* PubSub.sliding(4); - const layerNative = NativeTelemetryClient.layerTest({ - setExternalProcesses: (processes) => Ref.set(externalProcesses, processes), - readHistory: () => Effect.succeed(samples.slice(0, 2)), - sampleNow: Ref.modify(sampleIndex, (index) => { - const sampleIndex = Math.min(index, samples.length - 1); - return [nativeGeneration(samples[sampleIndex]!, sampleIndex === 2 ? 3 : 2), index + 1]; - }), - retry: Ref.updateAndGet(retryCount, (count) => count + 1).pipe(Effect.as(true)), - health: Ref.get(nativeHealth), - subscribeHealth: Effect.gen(function* () { - const subscription = yield* PubSub.subscribe(nativeHealthChanges); - const latest = yield* Ref.get(nativeHealth); - return { - latest, - changes: Stream.fromSubscription(subscription), - }; - }), - }); - const layerDesktop = DesktopTelemetryReceiver.layerTest({ - latest: Effect.succeedSome(desktopSnapshot(startedAt)), - health: Effect.succeed({ - status: "healthy", - lastSampleAt: Option.some(DateTime.makeUnsafe(startedAt)), - lastError: Option.none(), - }), - }); - const layerAttribution = ResourceAttribution.layer; - const layerDependencies = Layer.mergeAll(layerNative, layerDesktop, layerAttribution); - const layerTelemetry = ResourceTelemetry.layer.pipe(Layer.provide(layerDependencies)); - const layer = Layer.mergeAll(layerDependencies, layerTelemetry); - - yield* Effect.gen(function* () { - const telemetry = yield* ResourceTelemetry.ResourceTelemetry; - const attribution = yield* ResourceAttribution.ResourceAttribution; - - expect(yield* Ref.get(externalProcesses)).toEqual([{ pid: 5_000, startTimeMs: 300 }]); - - yield* attribution.record({ - component: "provider-event-log", - operation: "append", - logicalWriteBytes: 512, - count: 2, - durationMs: 4, - }); - const first = yield* telemetry.refresh; - expect(first.groups.backend.processCount).toBe(2); - expect(first.groups.electron.processCount).toBe(1); - expect(first.groups.monitor.processCount).toBe(1); - expect(first.power.onBattery).toBe("true"); - expect(Option.getOrNull(first.speedLimitPercent)).toBe(90); - expect(first.attribution.entries).toEqual([ - { - component: "provider-event-log", - operation: "append", - logicalReadBytes: 0, - logicalWriteBytes: 512, - count: 2, - durationMs: 4, - }, - ]); - - yield* TestClock.adjust(Duration.seconds(1)); - const second = yield* telemetry.refresh; - const codex = second.processes.find((entry) => entry.identity.pid === 4_242); - expect(codex?.cpuPercent).toBe(25); - expect(codex?.ioWriteBytesPerSecond).toBe(4_000); - expect(second.groups.backend.ioWriteBytes).toBe(4_000); - expect(second.health.collectionDurationMicros).toBe(300); - expect(second.health.scannedProcessCount).toBe(80); - expect(second.health.inaccessibleProcessCount).toBe(1); - - const history = yield* telemetry.readHistory({ - windowMs: 60_000, - bucketMs: 10_000, - }); - expect(history.retainedSampleCount).toBeGreaterThan(0); - expect( - history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.sampleCount, - ).toBe(2); - expect(history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.cpuTimeMs).toBe( - 250, - ); - expect( - history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.ioWriteBytes, - ).toBe(4_000); - expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe( - 4_000, - ); - - const retry = yield* telemetry.retry; - expect(retry.accepted).toBe(true); - expect(yield* Ref.get(retryCount)).toBe(1); - - yield* Ref.update(nativeHealth, (current) => ({ - ...current, - hello: Option.map(current.hello, (hello) => ({ - ...hello, - sidecarPid: 9_001, - })), - restartCount: 3, - })); - yield* TestClock.adjust(Duration.seconds(1)); - const restarted = yield* telemetry.refresh; - expect(DateTime.toEpochMillis(restarted.readAt)).toBe(startedAt + 2_000); - expect(Option.getOrNull(restarted.health.sidecarPid)).toBe(9_001); - - yield* Ref.update(nativeHealth, (current) => ({ - ...current, - status: "degraded" as const, - lastError: Option.some("collector exited"), - })); - yield* PubSub.publish(nativeHealthChanges, yield* Ref.get(nativeHealth)); - yield* Effect.yieldNow; - const healthUpdate = yield* telemetry.latest; - expect(healthUpdate.health.native.status).toBe("degraded"); - expect(Option.getOrNull(healthUpdate.health.native.lastError)).toBe("collector exited"); - const degradedHistory = yield* telemetry.readHistory({ - windowMs: 60_000, - bucketMs: 10_000, - }); - expect(degradedHistory.health.native.status).toBe("degraded"); - }).pipe(Effect.provide(layer)); - }), - ); }); diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetry.ts b/apps/server/src/resourceTelemetry/ResourceTelemetry.ts index 0563ab7b107a..2af6d98da0d1 100644 --- a/apps/server/src/resourceTelemetry/ResourceTelemetry.ts +++ b/apps/server/src/resourceTelemetry/ResourceTelemetry.ts @@ -333,8 +333,9 @@ export const make = Effect.fn("resourceTelemetry.resourceTelemetry.make")(functi const ingestNative = (snapshot: NativeTelemetryClient.NativeTelemetrySnapshot) => rebuild({ nativeSnapshot: snapshot, updatePrevious: true }); - const ingestDesktop = (snapshot: DesktopHostTelemetrySnapshot) => - Effect.gen(function* () { + const ingestDesktop = + /* Desktop samples arrive continuously; avoid a span per telemetry sample. */ + Effect.fnUntraced(function* (snapshot: DesktopHostTelemetrySnapshot) { const electronRoot = snapshot.electronProcesses.find( (process) => process.pid === snapshot.electronPid, ); @@ -412,39 +413,38 @@ export const make = Effect.fn("resourceTelemetry.resourceTelemetry.make")(functi ); const liveChanges = Stream.unwrap(Effect.map(subscribe, ({ changes }) => changes)); - const readHistory: ResourceTelemetry["Service"]["readHistory"] = (input) => - Effect.gen(function* () { - const readAt = yield* DateTime.now; - const normalizedInput = normalizeResourceTelemetryHistoryInput(input); - const historyResult = yield* Effect.result( - nativeClient.readHistory(normalizedInput.windowMs), - ); - if (Result.isFailure(historyResult)) { - yield* Effect.logWarning("Failed to read native resource telemetry history", { - cause: historyResult.failure.message, - }); - } - const [nativeHealth, desktopHealth] = yield* Effect.all([ - nativeClient.health, - desktopReceiver.health, - ]); - const current = yield* Ref.get(state); - return buildResourceTelemetryHistory({ - readAt, - windowMs: normalizedInput.windowMs, - bucketMs: normalizedInput.bucketMs, - sampleIntervalMs: nativeHealth.sampleIntervalMs, - serverPid: process.pid, - sidecarPid: Option.map(nativeHealth.hello, (hello) => hello.sidecarPid), - desktopSnapshot: current.desktopSnapshot, - snapshots: Result.isSuccess(historyResult) ? historyResult.success : [], - health: buildHealth({ - native: nativeHealth, - desktop: desktopHealth, - nativeSnapshot: current.nativeSnapshot, - }), + const readHistory: ResourceTelemetry["Service"]["readHistory"] = Effect.fn( + "ResourceTelemetry.readHistory", + )(function* (input) { + const readAt = yield* DateTime.now; + const normalizedInput = normalizeResourceTelemetryHistoryInput(input); + const historyResult = yield* Effect.result(nativeClient.readHistory(normalizedInput.windowMs)); + if (Result.isFailure(historyResult)) { + yield* Effect.logWarning("Failed to read native resource telemetry history", { + cause: historyResult.failure.message, }); + } + const [nativeHealth, desktopHealth] = yield* Effect.all([ + nativeClient.health, + desktopReceiver.health, + ]); + const current = yield* Ref.get(state); + return buildResourceTelemetryHistory({ + readAt, + windowMs: normalizedInput.windowMs, + bucketMs: normalizedInput.bucketMs, + sampleIntervalMs: nativeHealth.sampleIntervalMs, + serverPid: process.pid, + sidecarPid: Option.map(nativeHealth.hello, (hello) => hello.sidecarPid), + desktopSnapshot: current.desktopSnapshot, + snapshots: Result.isSuccess(historyResult) ? historyResult.success : [], + health: buildHealth({ + native: nativeHealth, + desktop: desktopHealth, + nativeSnapshot: current.nativeSnapshot, + }), }); + }); yield* nativeHealthSubscription.changes.pipe( Stream.runForEach(() => refreshHealth), Effect.forkScoped, diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts b/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts index fff4588c8468..236c0893bd7a 100644 --- a/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts @@ -8,10 +8,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; -import { - buildResourceTelemetryHistory, - normalizeResourceTelemetryHistoryInput, -} from "./ResourceTelemetryHistory.ts"; +import { buildResourceTelemetryHistory } from "./ResourceTelemetryHistory.ts"; const SERVER_PID = 100; const ELECTRON_PID = 200; @@ -132,13 +129,6 @@ function desktopSnapshot(): DesktopHostTelemetrySnapshot { } describe("buildResourceTelemetryHistory", () => { - it("normalizes query bounds before requesting native history", () => { - expect(normalizeResourceTelemetryHistoryInput({ windowMs: 0, bucketMs: 0 })).toEqual({ - windowMs: 1_000, - bucketMs: 1_000, - }); - }); - it("replays native snapshots on demand without applying current Electron metrics", () => { const history = buildResourceTelemetryHistory({ readAt: DateTime.makeUnsafe(STARTED_AT_MS + 2_000), @@ -230,24 +220,6 @@ describe("buildResourceTelemetryHistory", () => { expect(child?.ioWriteBytes).toBe(4_000); }); - it("uses an exact current Electron identity for slightly older native samples", () => { - const history = buildResourceTelemetryHistory({ - readAt: DateTime.makeUnsafe(STARTED_AT_MS + 2_000), - windowMs: 10_000, - bucketMs: 10_000, - sampleIntervalMs: 1_000, - serverPid: SERVER_PID, - sidecarPid: Option.none(), - desktopSnapshot: Option.some(desktopSnapshot()), - snapshots: [snapshot(1, STARTED_AT_MS, 100, 1_000)], - health, - }); - - expect( - history.topProcesses.find((process) => process.identity.pid === ELECTRON_PID)?.category, - ).toBe("electron-main"); - }); - it("uses the preceding sample as a baseline without attributing pre-window deltas", () => { const readAtMs = STARTED_AT_MS + 10_000; const history = buildResourceTelemetryHistory({ diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index 74602efb62a5..a9fb05ab1439 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -3,7 +3,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; import * as SqlClient from "effect/sql/SqlClient"; import { ProjectId } from "@t3tools/contracts"; @@ -121,39 +120,6 @@ describe("ReviewService", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("attributes file-content workspace violations to the file-content operation", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); - const outsideRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-outside-" }); - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); - const detectCalls: Array<{ readonly cwd: string }> = []; - - const error = yield* Effect.gen(function* () { - const review = yield* ReviewService.ReviewService; - return yield* review - .getDiffFileContents({ - cwd: outsideRoot, - sourceKind: "working-tree", - changeType: "change", - baseRef: "HEAD", - headRef: null, - oldPath: "file.ts", - newPath: "file.ts", - }) - .pipe(Effect.flip); - }).pipe(Effect.provide(layer({ workspaceRoot, baseDir, detectCalls }))); - - assert.strictEqual(error._tag, "VcsRepositoryDetectionError"); - assert.strictEqual(error.operation, "ReviewService.getDiffFileContents"); - assert.match( - "detail" in error ? error.detail : "", - /must stay within the configured workspace root/, - ); - assert.deepStrictEqual(detectCalls, []); - }).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("allows previous custom worktree locations but never a filesystem root", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -299,45 +265,4 @@ describe("ReviewService", () => { assert.strictEqual(result.cwd, checkout); }).pipe(Effect.provide(NodeServices.layer)), ); - - it.effect("allows diff preview cwd inside the configured workspace root", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); - const detectCalls: Array<{ readonly cwd: string }> = []; - - const result = yield* Effect.gen(function* () { - const review = yield* ReviewService.ReviewService; - return yield* review.getDiffPreview({ cwd: workspaceRoot }); - }).pipe(Effect.provide(layer({ workspaceRoot, baseDir, detectCalls }))); - - assert.strictEqual(result.cwd, workspaceRoot); - assert.deepStrictEqual(result.sources, []); - assert.deepStrictEqual(detectCalls, [{ cwd: workspaceRoot }]); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("preserves unexpected path-resolution failures", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); - const invalidCwd = `${workspaceRoot}\0invalid`; - const detectCalls: Array<{ readonly cwd: string }> = []; - - const error = yield* Effect.gen(function* () { - const review = yield* ReviewService.ReviewService; - return yield* review.getDiffPreview({ cwd: invalidCwd }).pipe(Effect.flip); - }).pipe(Effect.provide(layer({ workspaceRoot, baseDir, detectCalls }))); - - assert.strictEqual(error._tag, "VcsRepositoryDetectionError"); - if (error._tag !== "VcsRepositoryDetectionError") return; - assert.strictEqual(error.operation, "ReviewService.assertWorkspaceBoundCwd.canonicalizePath"); - assert.strictEqual(error.cwd, invalidCwd); - assert.match(error.detail, /Failed to resolve a path/); - assert.instanceOf(error.cause, PlatformError.PlatformError); - assert.deepStrictEqual(detectCalls, []); - }).pipe(Effect.provide(NodeServices.layer)), - ); }); diff --git a/apps/server/src/rpcInitialItems.memory.test.ts b/apps/server/src/rpcInitialItems.memory.test.ts deleted file mode 100644 index 981269d52c70..000000000000 --- a/apps/server/src/rpcInitialItems.memory.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - memory assertions run in an isolated Node process with explicit GC. -import * as NodeChildProcess from "node:child_process"; -import * as NodeURL from "node:url"; -import * as NodeUtil from "node:util"; -import { expect, it } from "vite-plus/test"; - -const execFile = NodeUtil.promisify(NodeChildProcess.execFile); - -it.each(["prefix", "snapshot", "replay"])( - "releases delivered %s history while RPC subscriptions remain open", - async (mode) => { - const root = NodeURL.fileURLToPath(new URL("../../../", import.meta.url)); - const fixture = NodeURL.fileURLToPath( - new URL("./testkit/RpcInitialMemory.fixture.mjs", import.meta.url), - ); - const { stdout } = await execFile(process.execPath, ["--expose-gc", fixture, root, mode]); - const result = JSON.parse(stdout) as { - checkpoints: ReadonlyArray<{ subscriptions: number; retained: number }>; - }; - expect( - result.checkpoints.map(({ subscriptions, retained }) => ({ subscriptions, retained })), - ).toEqual([ - { subscriptions: 2, retained: 0 }, - { subscriptions: 4, retained: 0 }, - ]); - }, -); diff --git a/apps/server/src/scheduledTasks/RelayDeliveryProof.ts b/apps/server/src/scheduledTasks/RelayDeliveryProof.ts index 767d2fa7bb96..abf7c148acc7 100644 --- a/apps/server/src/scheduledTasks/RelayDeliveryProof.ts +++ b/apps/server/src/scheduledTasks/RelayDeliveryProof.ts @@ -44,8 +44,8 @@ export class RelayDeliveryProof extends Context.Service< const make = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; const environment = yield* ServerEnvironment.ServerEnvironment; - const verify: RelayDeliveryProof["Service"]["verify"] = (input) => - Effect.gen(function* () { + const verify: RelayDeliveryProof["Service"]["verify"] = Effect.fn("webhook.verifyRelayDelivery")( + function* (input) { const proof = input.headers[RELAY_HOOK_DELIVERY_HEADER]; const deliveryId = input.headers["x-t3-relay-delivery-id"]; const receivedAt = input.headers["x-t3-relay-received-at"]; @@ -80,7 +80,9 @@ const make = Effect.gen(function* () { return Option.none(); } return Option.some({ deliveryId, receivedAt }); - }).pipe(Effect.orElseSucceed(Option.none), Effect.withSpan("webhook.verifyRelayDelivery")); + }, + Effect.orElseSucceed(Option.none), + ); return RelayDeliveryProof.of({ verify }); }); diff --git a/apps/server/src/scheduledTasks/Schedule.test.ts b/apps/server/src/scheduledTasks/Schedule.test.ts index e989768188d0..f5973969747f 100644 --- a/apps/server/src/scheduledTasks/Schedule.test.ts +++ b/apps/server/src/scheduledTasks/Schedule.test.ts @@ -1,36 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; -import { - isMissedFixedTimeRun, - isSameSchedule, - nextScheduledRunAt, - parseTimeOfDay, -} from "./Schedule.ts"; +import { isSameSchedule, nextScheduledRunAt } from "./Schedule.ts"; describe("scheduled task schedule calculation", () => { - it("parses 24-hour times", () => { - expect(parseTimeOfDay("09:30")).toEqual({ hour: 9, minute: 30 }); - expect(parseTimeOfDay("23:59")).toEqual({ hour: 23, minute: 59 }); - expect(parseTimeOfDay("25:00")).toBeNull(); - }); - - it("calculates interval schedules from the supplied instant", () => { - const next = nextScheduledRunAt( - { type: "interval", everyMs: 5 * 60_000 }, - DateTime.makeUnsafe("2026-07-01T16:00:00.000Z"), - ); - expect(next ? DateTime.formatIso(DateTime.toUtc(next)) : null).toBe("2026-07-01T16:05:00.000Z"); - }); - - it("clamps legacy sub-minute intervals to the one-minute execution floor", () => { - const next = nextScheduledRunAt( - { type: "interval", everyMs: 1_000 }, - DateTime.makeUnsafe("2026-07-01T16:00:00.000Z"), - ); - expect(next ? DateTime.formatIso(DateTime.toUtc(next)) : null).toBe("2026-07-01T16:01:00.000Z"); - }); - it("skips to the next matching fixed-time weekday", () => { const next = nextScheduledRunAt( { type: "fixed_time", timeOfDay: "09:00", weekdays: [1, 2, 3, 4, 5] }, @@ -53,18 +26,6 @@ describe("scheduled task schedule calculation", () => { expect(parts?.minute).toBe(0); }); - it("skips fixed-time runs missed by more than the grace window", () => { - const fixedTime = { type: "fixed_time", timeOfDay: "09:00" } as const; - const dueAt = DateTime.makeUnsafe("2026-07-01T09:00:00.000Z"); - const withinGrace = DateTime.makeUnsafe("2026-07-01T09:05:00.000Z"); - const pastGrace = DateTime.makeUnsafe("2026-07-01T15:00:00.000Z"); - expect(isMissedFixedTimeRun(fixedTime, dueAt, withinGrace)).toBe(false); - expect(isMissedFixedTimeRun(fixedTime, dueAt, pastGrace)).toBe(true); - expect(isMissedFixedTimeRun({ type: "interval", everyMs: 60_000 }, dueAt, pastGrace)).toBe( - false, - ); - }); - it("compares schedules structurally", () => { expect( isSameSchedule({ type: "interval", everyMs: 60_000 }, { type: "interval", everyMs: 60_000 }), @@ -115,17 +76,4 @@ describe("scheduled task schedule calculation", () => { ), ).toBe(false); }); - - it.each([ - ["9:00", "09:00"], - ["09:00", "9:00"], - ["0:30", "00:30"], - ])("treats %s and %s as the same fixed-time schedule", (before, after) => { - expect( - isSameSchedule( - { type: "fixed_time", timeOfDay: before }, - { type: "fixed_time", timeOfDay: after }, - ), - ).toBe(true); - }); }); diff --git a/apps/server/src/scheduledTasks/Schedule.ts b/apps/server/src/scheduledTasks/Schedule.ts index 63c1526f1ca0..4cabb3539336 100644 --- a/apps/server/src/scheduledTasks/Schedule.ts +++ b/apps/server/src/scheduledTasks/Schedule.ts @@ -3,7 +3,7 @@ import * as DateTime from "effect/DateTime"; const MINUTE_MS = 60_000; -export function parseTimeOfDay(value: string): { hour: number; minute: number } | null { +function parseTimeOfDay(value: string): { hour: number; minute: number } | null { const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(value.trim()); if (!match) return null; return { hour: Number(match[1]), minute: Number(match[2]) }; diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts deleted file mode 100644 index be561bea036e..000000000000 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { expect, it } from "@effect/vitest"; -import { ScheduledTaskUpsertInput } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; -import { afterEach, vi } from "vite-plus/test"; - -import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; -import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; -import * as SqlitePersistence from "../persistence/Sqlite.ts"; -import * as Scheduler from "../scheduling/Scheduler.ts"; -import * as SecretRequests from "../secrets/SecretRequests.ts"; -import { ServerActivation } from "../serverActivation.ts"; -import * as ScheduledTaskService from "./ScheduledTaskService.ts"; - -afterEach(() => vi.unstubAllEnvs()); - -const dependencies = Layer.mergeAll( - NodeCrypto.layer, - Scheduler.layer, - Layer.mock(ThreadLaunchService.ThreadLaunchService)({ - launch: () => Effect.die("synthetic dispatch failure"), - }), - Layer.mock(ThreadManagementService.ThreadManagementService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), -); -const serviceLayer = ScheduledTaskService.layer.pipe(Layer.provide(dependencies)); -const decodeInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput); -const input = { - commandId: "interval-duration", - title: "Interval review", - prompt: "Review the open pull requests.", - enabled: true, - projectId: "project-interval-duration", - workspaceStrategy: { type: "root" }, - modelSelection: { instanceId: "codex", model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", -}; - -it.effect.each([ - { - zone: "America/New_York", - from: "2026-03-08T06:30:00.000Z", - everyMs: 7_200_000, - expected: "2026-03-08T08:30:00.000Z", - }, - { - zone: "America/New_York", - from: "2026-11-01T05:30:00.000Z", - everyMs: 7_200_000, - expected: "2026-11-01T07:30:00.000Z", - }, - { - zone: "America/New_York", - from: "2026-11-01T06:30:00.000Z", - everyMs: 60_000, - expected: "2026-11-01T06:31:00.000Z", - }, - { - zone: "UTC", - from: "2026-11-01T06:30:00.000Z", - everyMs: 60_000, - expected: "2026-11-01T06:31:00.000Z", - }, - { - zone: "Asia/Tokyo", - from: "2026-03-08T06:30:00.000Z", - everyMs: 7_200_000, - expected: "2026-03-08T08:30:00.000Z", - }, -] as const)("persists elapsed interval $zone from $from", ({ zone, from, everyMs, expected }) => - Effect.gen(function* () { - vi.stubEnv("TZ", zone); - yield* TestClock.setTime(Date.parse(from)); - yield* Effect.gen(function* () { - const service = yield* ScheduledTaskService.ScheduledTaskService; - const decoded = yield* decodeInput({ ...input, schedule: { type: "interval", everyMs } }); - const created = yield* service.upsert(decoded); - expect(created.task.nextRunAt).toBe(expected); - expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expected); - - const paused = yield* service.setEnabled({ id: created.task.id, enabled: false }); - expect(paused.task.nextRunAt).toBeNull(); - const resumed = yield* service.setEnabled({ id: created.task.id, enabled: true }); - expect(resumed.task.nextRunAt).toBe(expected); - - // Exercise completion rescheduling without starting any provider process. - const completed = yield* service.runNow({ id: created.task.id }); - expect(completed.task.lastRunStatus).toBe("failed"); - expect(completed.task.runCount).toBe(1); - expect(completed.task.nextRunAt).toBe(expected); - expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expected); - }).pipe(Effect.provide(serviceLayer)); - }).pipe( - // Keep the real scheduler parked while exercising finite service actions. - Effect.provideService(ServerActivation, Effect.never), - Effect.provide(SqlitePersistence.layerMemory), - ), -); - -it.effect.each([ - { from: "2026-03-07T23:30:00.000Z", expected: "2026-03-08T13:00:00.000Z" }, - { from: "2026-10-31T23:30:00.000Z", expected: "2026-11-01T14:00:00.000Z" }, -] as const)("keeps fixed-time schedules on the local calendar from $from", ({ from, expected }) => - Effect.gen(function* () { - vi.stubEnv("TZ", "America/New_York"); - yield* TestClock.setTime(Date.parse(from)); - yield* Effect.gen(function* () { - const service = yield* ScheduledTaskService.ScheduledTaskService; - const decoded = yield* decodeInput({ - ...input, - schedule: { type: "fixed_time", timeOfDay: "09:00" }, - }); - const created = yield* service.upsert(decoded); - expect(created.task.nextRunAt).toBe(expected); - expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expected); - }).pipe(Effect.provide(serviceLayer)); - }).pipe( - Effect.provideService(ServerActivation, Effect.never), - Effect.provide(SqlitePersistence.layerMemory), - ), -); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index c562134f0d8b..09e1d7674044 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -11,6 +11,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ScheduledTaskService from "./ScheduledTaskService.ts"; @@ -22,6 +23,10 @@ it.effect("rejects a stale form save after deletion while preserving explicit-id NodeCrypto.layer, Scheduler.layer, Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), Layer.mock(ThreadManagementService.ThreadManagementService)({}), Layer.mock(SecretRequests.SecretRequests)({}), ); @@ -65,6 +70,10 @@ it.effect("preserves a due run when a save only pads the scheduled hour", () => NodeCrypto.layer, Scheduler.layer, Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), Layer.mock(ThreadManagementService.ThreadManagementService)({}), Layer.mock(SecretRequests.SecretRequests)({}), ); @@ -107,3 +116,54 @@ it.effect("preserves a due run when a save only pads the scheduled hour", () => }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); + +it.effect("keeps the task list order when a toggle or edit rewrites updated_at", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-07-01T09:00:00.000Z")); + const layerDependencies = Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const input = (id: string) => ({ + id, + title: id, + prompt: "Review the open pull requests.", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: "project-stable-order", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + const listedIds = service + .list() + .pipe(Effect.map(({ tasks }) => tasks.map((task) => String(task.id)))); + + const older = yield* service.upsert(yield* decodeUpsertInput(input("scheduled-task:older"))); + yield* TestClock.adjust("1 second"); + yield* service.upsert(yield* decodeUpsertInput(input("scheduled-task:newer"))); + const order = ["scheduled-task:newer", "scheduled-task:older"]; + expect(yield* listedIds).toEqual(order); + + yield* TestClock.adjust("1 second"); + const toggled = yield* service.setEnabled({ id: older.task.id, enabled: false }); + expect(toggled.task.updatedAt > older.task.updatedAt).toBe(true); + expect(yield* listedIds).toEqual(order); + + yield* TestClock.adjust("1 second"); + const edit = { ...input(older.task.id), requireExisting: true, title: "Edited" }; + yield* service.upsert(yield* decodeUpsertInput({ ...edit, enabled: false })); + expect(yield* listedIds).toEqual(order); + }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index 8b8dc61850a4..466af14e7f22 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -3,13 +3,21 @@ import * as NodeUtil from "node:util"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { assert, it } from "@effect/vitest"; -import { ScheduledTaskError } from "@t3tools/contracts"; +import { + ProjectId, + ProviderInstanceId, + ScheduledTaskError, + ScheduledTaskId, + ThreadId, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as SqlClient from "effect/sql/SqlClient"; +import * as Fiber from "effect/Fiber"; +import * as Stream from "effect/Stream"; import * as Deferred from "effect/Deferred"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -18,6 +26,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ScheduledTaskService from "./ScheduledTaskService.ts"; @@ -195,6 +204,10 @@ it.effect( Effect.andThen(Effect.die(new Error("test launch failure"))), ), }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), Layer.mock(ThreadManagementService.ThreadManagementService)({}), Layer.mock(SecretRequests.SecretRequests)({}), NodeCrypto.layer, @@ -289,6 +302,10 @@ it.effect( Effect.andThen(Effect.die(new Error("test launch failure"))), ), }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), Layer.mock(ThreadManagementService.ThreadManagementService)({}), Layer.mock(SecretRequests.SecretRequests)({}), NodeCrypto.layer, @@ -321,3 +338,70 @@ it.effect( ); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); + +it.effect("thread cleanup notifies subscribers and a finishing run cannot recreate the task", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const subscribed = yield* Deferred.make(); + const running = yield* Deferred.make(); + const removed = yield* Deferred.make(); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + sendToThread: () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(Effect.die("test dispatch failure")), + ), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + NodeCrypto.layer, + Scheduler.layer, + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const threadId = ThreadId.make("thread:deleted-during-run"); + const { task } = yield* service.upsert({ + id: ScheduledTaskId.make("task:deleted-during-run"), + title: "Check orchestration", + prompt: "Check progress", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: ProjectId.make("project:test"), + threadId, + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + yield* service.subscribeList().pipe( + Stream.runForEach(({ tasks }) => { + const current = tasks.find((candidate) => candidate.id === task.id); + if (current === undefined) return Deferred.succeed(removed, undefined); + return current.lastRunStatus === "running" + ? Deferred.succeed(running, undefined) + : Deferred.succeed(subscribed, undefined); + }), + Effect.forkChild, + ); + yield* Deferred.await(subscribed); + const run = yield* service.runNow({ id: task.id }).pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Deferred.await(running); + yield* service.deleteForThread(threadId); + yield* Deferred.await(removed); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(run); + assert.lengthOf((yield* service.list()).tasks, 0); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 82899bba752d..374d6b38027b 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -15,6 +15,7 @@ import { type ScheduledTaskListWebhookDeliveriesResult, type ScheduledTaskRotateWebhookTokenInput, type ScheduledTaskWebhookDeliveryOutcome, + type ScheduledTaskWebhookFilter, type ScheduledTaskListResult, type ScheduledTaskMutationResult, type ScheduledTaskRunNowInput, @@ -28,6 +29,7 @@ import * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Duration from "effect/Duration"; import * as Metric from "effect/Metric"; import * as Layer from "effect/Layer"; @@ -41,6 +43,7 @@ import * as Stream from "effect/Stream"; import * as SqlClient from "effect/sql/SqlClient"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; +import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as Metrics from "../observability/Metrics.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; @@ -52,15 +55,38 @@ import { renderWebhookPrompt, type WebhookRequest, } from "./webhookTemplate.ts"; +import { + acceptWebhookDedupe, + acceptImmediateWebhookDedupe, + releaseWebhookReplay, + decodePendingTask, + decodePendingRequest, + dedupeError, +} from "./webhookDedupe.ts"; +import { describeFailedWebhookFilter, findFailedWebhookFilter } from "./webhookFilter.ts"; import { constantTimeEquals, verifyWebhookSignature } from "./webhookVerification.ts"; export const WEBHOOK_ROUTE_PREFIX = "/api/hooks"; +const isScheduledTaskError = Schema.is(ScheduledTaskError); +const PROMPT_TOO_LONG = "The filled-in prompt is too long."; const WEBHOOK_DELIVERY_RETENTION = 50; +const WEBHOOK_DEDUPE_DELIVERY_RETENTION = 20; +const WEBHOOK_FILTERED_DELIVERY_RETENTION = 20; +const WEBHOOK_DEDUPE_LOG_PER_MINUTE = 10; +const WEBHOOK_FILTERED_LOG_PER_MINUTE = 10; const WEBHOOK_DELIVERY_LOG_BODY_LIMIT = 64 * 1024; const WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT = 64 * 1024; const WEBHOOK_MAX_QUEUED_PER_TASK = 20; /** Accepted deliveries per task per minute, enforced here as well as on the relay because the tunnel hostname is public too. */ const WEBHOOK_RATE_LIMIT_PER_MINUTE = 60; +const LEGACY_DELIVERY_OUTCOMES: ReadonlySet = new Set([ + "accepted", + "dispatch_failed", + "rejected_signature", + "disabled", + "rate_limited", + "expired", +]); interface WebhookOrigin { readonly relayHookBaseUrl: string | null; @@ -92,7 +118,7 @@ class WebhookDeliverySkipped extends Data.TaggedError("WebhookDeliverySkipped")< }> {} interface RateWindow { - readonly accepted: ReadonlyArray; + readonly accepted: ReadonlyArray<{ readonly at: number }>; readonly rejectedLogged: boolean; } @@ -107,12 +133,14 @@ export interface WebhookTriggerRequest extends WebhookRequest { export type WebhookDeliveryOutcome = | "accepted" | "duplicate" + | "coalesced" | "prompt_too_long" | "queue_full" | "rate_limited" | "disabled" | "rejected_signature" | "expired" + | "filtered" | "not_found" | "error"; @@ -120,7 +148,7 @@ export type WebhookTriggerResult = | { readonly _tag: "accepted"; readonly deliveryId: ScheduledTaskWebhookDeliveryId; - readonly outcome: "accepted" | "duplicate" | "prompt_too_long"; + readonly outcome: "accepted" | "duplicate" | "coalesced" | "prompt_too_long" | "filtered"; } | { readonly _tag: "not_found" } | { readonly _tag: "rejected_signature" } @@ -131,6 +159,9 @@ export type WebhookTriggerResult = } | { readonly _tag: "expired" }; +const webhookFilters = (filters: ReadonlyArray | undefined) => + filters?.length ? { filters } : {}; + const decodeTask = Schema.decodeUnknownEffect(ScheduledTask); const decodeTaskId = Schema.decodeUnknownOption(ScheduledTaskId); const decodeScheduleJson = Schema.decodeUnknownEffect( @@ -188,6 +219,7 @@ interface WebhookDeliveryRow { readonly signature_verified: number; readonly missing_fields_json: string; readonly rendered_prompt: string | null; + readonly pending_window: number; readonly error: string | null; } @@ -205,9 +237,15 @@ export class ScheduledTaskService extends Context.Service< readonly delete: ( input: ScheduledTaskDeleteInput, ) => Effect.Effect; + /** Deletes only tasks that still target this thread, including their webhook deliveries. */ + readonly deleteForThread: (threadId: ThreadId) => Effect.Effect; + /** The dispatched thread, or null if dispatch failed; leaves the task's binding unchanged. */ readonly runNow: ( input: ScheduledTaskRunNowInput, - ) => Effect.Effect; + ) => Effect.Effect< + ScheduledTaskRunNowResult & { readonly threadId: ThreadId | null }, + ScheduledTaskError + >; readonly rotateWebhookToken: ( input: ScheduledTaskRotateWebhookTokenInput, ) => Effect.Effect; @@ -280,8 +318,9 @@ function webhookEndpoint( }; } -const decodeRow = (row: ScheduledTaskRow, origin: WebhookOrigin | null = null) => - Effect.gen(function* () { +// Runs per task row; list and lookup operations already own the span. +const decodeRow = Effect.fnUntraced( + function* (row: ScheduledTaskRow, origin: WebhookOrigin | null = null) { const schedule = yield* decodeScheduleJson(row.schedule_json); const webhook = schedule.type === "webhook" ? webhookEndpoint(row, origin) : undefined; const workspaceStrategy = yield* decodeWorkspaceStrategyJson(row.workspace_strategy_json); @@ -309,15 +348,18 @@ const decodeRow = (row: ScheduledTaskRow, origin: WebhookOrigin | null = null) = runCount: row.run_count, ...(webhook === undefined ? {} : { webhook }), }); - }).pipe( - Effect.mapError((cause) => { - const taskId = decodeTaskId(row.task_id); - return taskError("Could not decode schedule task row.", { - ...(Option.isSome(taskId) ? { taskId: taskId.value } : {}), - cause, - }); - }), - ); + }, + (effect, row, _origin?) => + effect.pipe( + Effect.mapError((cause) => { + const taskId = decodeTaskId(row.task_id); + return taskError("Could not decode schedule task row.", { + ...(Option.isSome(taskId) ? { taskId: taskId.value } : {}), + cause, + }); + }), + ), +); export const listDueTasks = Effect.fn("ScheduledTaskService.listDueTasks")(function* ( now: DateTime.DateTime, @@ -361,6 +403,7 @@ export const layer = Layer.effect( const crypto = yield* Crypto.Crypto; const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; const threadManagement = yield* ThreadManagementService.ThreadManagementService; + const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; const secretRequests = yield* SecretRequests.SecretRequests; const scheduler = yield* Scheduler.Scheduler; const readWebhookOrigin = yield* ScheduledTaskWebhookOrigin; @@ -371,6 +414,16 @@ export const layer = Layer.effect( // finish late release their own count, never a recreated task's. const webhookQueued = yield* Ref.make>(new Map()); const webhookRateWindows = yield* Ref.make>(new Map()); + const webhookRejectedRateWindows = yield* Ref.make>( + new Map(), + ); + const webhookDedupeLogWindows = yield* Ref.make< + ReadonlyMap> + >(new Map()); + const pendingInFlight = yield* Ref.make>(new Set()); + const webhookFilteredLogWindows = yield* Ref.make< + ReadonlyMap> + >(new Map()); const activeRuns = yield* Ref.make>(new Set()); // Sliding(1) coalesces the dirty-signal: every notification triggers a // full list() re-emit anyway, so a slow subscriber only ever needs the @@ -403,7 +456,9 @@ export const layer = Layer.effect( webhook_token, webhook_secret FROM scheduled_tasks - ORDER BY updated_at DESC, task_id ASC + -- Not updated_at: toggles, runs and edits rewrite it, and clients render + -- this order as-is, so rows would move under the pointer. + ORDER BY created_at DESC, task_id ASC `; const listRows = Effect.fn("ScheduledTaskService.listRows")(function* () { @@ -590,11 +645,12 @@ export const layer = Layer.effect( ), ); - const markRunning = (id: ScheduledTaskId, startedAtIso: string) => + const markRunning = (id: ScheduledTaskId, startedAtIso: string, webhookDeliveryId?: string) => sql<{ task_id: string }>` UPDATE scheduled_tasks SET updated_at = ${startedAtIso}, last_run_at = ${startedAtIso}, + last_run_webhook_delivery_id = ${webhookDeliveryId ?? null}, last_run_status = 'running', last_run_error = NULL WHERE task_id = ${id} @@ -636,9 +692,10 @@ export const layer = Layer.effect( // markCompleted, write a full terminal record so runDueTasks neither skips // the task forever (it filters out 'running' rows) nor re-fires it // immediately: the dispatch may already have gone out, so next_run_at must - // advance and run_count must count the attempt. - const releaseStuckRun = (task: ScheduledTask, message: string) => - Effect.gen(function* () { + // advance. Pending webhook attempts count only with their completion state; + // other attempts count here. + const releaseStuckRun = Effect.fn("ScheduledTaskService.releaseStuckRun")( + function* (task: ScheduledTask, message: string, pendingDeliveryId?: string) { const now = yield* localNow; const reread = yield* Effect.result(findTask(task.id)); if (Result.isSuccess(reread) && reread.success === null) return; @@ -649,23 +706,33 @@ export const layer = Layer.effect( last_run_error = ${message}, next_run_at = ${nextRunAt(source, now)}, updated_at = ${iso(now)}, - run_count = run_count + 1 + run_count = run_count + CASE WHEN EXISTS ( + SELECT 1 FROM scheduled_task_webhook_pending + WHERE delivery_id = ${pendingDeliveryId ?? null} AND state = 'running' + ) THEN 0 ELSE 1 END WHERE task_id = ${task.id} AND last_run_status = 'running' `; yield* notifyChanged; - }).pipe( - Effect.catch((cause) => - Effect.logWarning("Could not release stuck schedule task run", { - taskId: task.id, - cause, - }), + }, + (effect, task, _message, _pendingDeliveryId) => + effect.pipe( + Effect.catch((cause) => + Effect.logWarning("Could not release stuck schedule task run", { + taskId: task.id, + cause, + }), + ), ), - ); + ); const runTask = Effect.fn("ScheduledTaskService.runTask")(function* ( task: ScheduledTask, trigger: "scheduled" | "manual" | "webhook", - webhook?: { readonly deliveryId: string; readonly prompt: string }, + webhook?: { + readonly deliveryId: string; + readonly prompt: string; + readonly dispatched: Ref.Ref; + }, ) { const reserved = yield* Ref.modify(activeRuns, (active) => { if (active.has(task.id)) return [false, active] as const; @@ -677,7 +744,7 @@ export const layer = Layer.effect( if (trigger !== "scheduled") { return yield* taskError("Schedule task is already running.", { taskId: task.id }); } - return task; + return { task, threadId: null }; } return yield* Effect.gen(function* () { @@ -697,8 +764,14 @@ export const layer = Layer.effect( if (trigger !== "scheduled") { return yield* taskError("Schedule task not found.", { taskId: task.id }); } - return task; + return { task, threadId: null }; } + if ( + webhook !== undefined && + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId} AND state = 'completed'`) + .length > 0 + ) + return { task: { ...active, lastRunStatus: "succeeded" as const }, threadId: null }; const parsedNextRunAt = active.nextRunAt === null ? Option.none() : DateTime.make(active.nextRunAt); if ( @@ -707,7 +780,7 @@ export const layer = Layer.effect( Option.isNone(parsedNextRunAt) || DateTime.toEpochMillis(parsedNextRunAt.value) > DateTime.toEpochMillis(startedAt)) ) { - return active; + return { task: active, threadId: null }; } // A queued delivery must not run a task that was paused, deleted and // recreated under the same id, or switched to another trigger while @@ -724,7 +797,22 @@ export const layer = Layer.effect( if (reason !== null) return yield* new WebhookDeliverySkipped({ reason }); } - yield* markRunning(active.id, startedAtIso); + if (webhook === undefined) yield* markRunning(active.id, startedAtIso); + else + yield* sql + .withTransaction( + Effect.gen(function* () { + yield* markRunning(active.id, startedAtIso, webhook.deliveryId); + // Recovery must distinguish this delivery from other windows waiting on the same task. + yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'running' WHERE delivery_id = ${webhook.deliveryId}`; + }), + ) + .pipe( + Effect.mapError((cause) => + taskError("Could not start webhook run.", { taskId: task.id, cause }), + ), + ); + yield* notifyChanged; const fireKey = @@ -742,45 +830,51 @@ export const layer = Layer.effect( const result = active.threadId === null ? yield* Effect.exit( - threadLaunch.launch({ - commandId, - projectId: active.projectId, - title: active.title, - modelSelection: active.modelSelection, - runtimeMode: active.runtimeMode, - interactionMode: active.interactionMode, - workspaceStrategy: active.workspaceStrategy, - initialMessage: { - messageId, - scheduledTaskId: active.id, - text: prompt, - attachments: [], - }, - createdBy: active.createdBy, - creationSource: active.creationSource, - }), + threadLaunch + .launch({ + commandId, + projectId: active.projectId, + title: active.title, + modelSelection: active.modelSelection, + runtimeMode: active.runtimeMode, + interactionMode: active.interactionMode, + workspaceStrategy: active.workspaceStrategy, + initialMessage: { + messageId, + scheduledTaskId: active.id, + text: prompt, + attachments: [], + }, + createdBy: active.createdBy, + creationSource: active.creationSource, + }) + .pipe(Effect.map((launched) => launched.threadId)), ) : yield* Effect.exit( // The stored selection only seeds fresh threads. A bound run // follows the thread's current provider, so it passes none. - threadManagement.sendToThread({ - projectId: active.projectId, - commandId, - threadId: ThreadId.make(active.threadId), - messageId, - scheduledTaskId: active.id, - text: prompt, - attachments: [], - mode: "queue", - createdBy: active.createdBy, - creationSource: active.creationSource, - }), + threadManagement + .sendToThread({ + projectId: active.projectId, + commandId, + threadId: ThreadId.make(active.threadId), + messageId, + scheduledTaskId: active.id, + text: prompt, + attachments: [], + mode: "queue", + createdBy: active.createdBy, + creationSource: active.creationSource, + }) + .pipe(Effect.as(ThreadId.make(active.threadId))), ); - const completedAt = yield* localNow; const runSucceeded = result._tag === "Success"; + if (webhook !== undefined && runSucceeded) yield* Ref.set(webhook.dispatched, true); + const completedAt = yield* localNow; const lastRunStatus = runSucceeded ? ("succeeded" as const) : ("failed" as const); const lastRunError = runSucceeded ? null : errorMessage(result.cause); + const threadId = runSucceeded ? result.value : null; const current = yield* findTask(task.id); const scheduleSource = current ?? task; const completed: ScheduledTask = { @@ -796,7 +890,7 @@ export const layer = Layer.effect( // startedAtIso in the guard ensures this writes only to the row this // run marked as running — a task deleted mid-run and recreated with // the same id (idempotent commandId replay) must not be stamped. - yield* markCompleted({ + const completion = markCompleted({ id: task.id, completedAtIso: completed.updatedAt, nextRunAtIso: completed.nextRunAt, @@ -804,11 +898,32 @@ export const layer = Layer.effect( error: lastRunError, startedAtIso, }); + if (webhook === undefined) yield* completion; + else + yield* sql + .withTransaction( + Effect.gen(function* () { + const pending = + yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId}`; + if (pending.length === 0) yield* completion; + else { + const counted = + yield* sql`UPDATE scheduled_task_webhook_pending SET state = ${runSucceeded ? "completed" : "failed"} + WHERE delivery_id = ${webhook.deliveryId} AND state NOT IN ('completed', 'failed') RETURNING delivery_id`; + if (counted.length > 0) yield* completion; + } + }), + ) + .pipe( + Effect.mapError((cause) => + taskError("Could not record webhook completion.", { taskId: task.id, cause }), + ), + ); yield* notifyChanged; } - return completed; + return { task: completed, threadId }; }).pipe( - Effect.onError((cause) => releaseStuckRun(task, errorMessage(cause))), + Effect.onError((cause) => releaseStuckRun(task, errorMessage(cause), webhook?.deliveryId)), Effect.ensuring( Ref.update(activeRuns, (active) => { const next = new Set(active); @@ -885,7 +1000,11 @@ export const layer = Layer.effect( last_run_error = 'Run was interrupted by a server restart.', next_run_at = ${nextRunAt(decoded.success, now)}, updated_at = ${iso(now)}, - run_count = run_count + 1 + run_count = run_count + CASE WHEN EXISTS ( + SELECT 1 FROM scheduled_task_webhook_pending + WHERE task_id IS ${row.task_id} AND state = 'running' + AND delivery_id = scheduled_tasks.last_run_webhook_delivery_id + ) THEN 0 ELSE 1 END WHERE task_id IS ${row.task_id} AND last_run_status = 'running' `; return; @@ -899,7 +1018,11 @@ export const layer = Layer.effect( SET last_run_status = 'failed', last_run_error = 'Run was interrupted by a server restart.', updated_at = ${iso(now)}, - run_count = run_count + 1 + run_count = run_count + CASE WHEN EXISTS ( + SELECT 1 FROM scheduled_task_webhook_pending + WHERE task_id IS ${row.task_id} AND state = 'running' + AND delivery_id = scheduled_tasks.last_run_webhook_delivery_id + ) THEN 0 ELSE 1 END WHERE task_id IS ${row.task_id} AND last_run_status = 'running' `; }), @@ -933,125 +1056,145 @@ export const layer = Layer.effect( }), ); - const upsert: ScheduledTaskService["Service"]["upsert"] = (input) => - Effect.gen(function* () { - const now = yield* localNow; - const uuid = - input.commandId === undefined - ? yield* crypto - .randomUUIDv4() - .pipe( - Effect.mapError((cause) => - taskError("Could not generate schedule task id.", { cause }), - ), - ) - : null; - const id = - input.id ?? - ScheduledTaskId.make( - input.commandId ? `scheduled-task:${input.commandId}` : `scheduled-task:${uuid}`, - ); - // Look up by the *resolved* id so idempotent creates (commandId replays) - // keep their run history, and so real load failures propagate instead - // of silently resetting an existing row. - const existingTask = yield* findTask(id); - // Keep the existing next_run_at when the schedule itself is untouched: - // editing a title or prompt must not postpone (or resurrect) a due - // run — only schedule/enabled changes restart the clock. - const schedule: ScheduledTask["schedule"] = - input.schedule.type === "webhook" - ? { - type: "webhook", - signature: - input.schedule.signature == null - ? null - : { - header: input.schedule.signature.header.toLowerCase(), - encoding: input.schedule.signature.encoding, - prefix: input.schedule.signature.prefix, - }, - maxDeliveryAgeMinutes: input.schedule.maxDeliveryAgeMinutes ?? null, + const upsert: ScheduledTaskService["Service"]["upsert"] = Effect.fn( + "ScheduledTaskService.upsert", + )(function* (input) { + const now = yield* localNow; + const uuid = + input.commandId === undefined + ? yield* crypto + .randomUUIDv4() + .pipe( + Effect.mapError((cause) => + taskError("Could not generate schedule task id.", { cause }), + ), + ) + : null; + const id = + input.id ?? + ScheduledTaskId.make( + input.commandId ? `scheduled-task:${input.commandId}` : `scheduled-task:${uuid}`, + ); + // Look up by the *resolved* id so idempotent creates (commandId replays) + // keep their run history, and so real load failures propagate instead + // of silently resetting an existing row. + const existingTask = yield* findTask(id); + // Keep the existing next_run_at when the schedule itself is untouched: + // editing a title or prompt must not postpone (or resurrect) a due + // run — only schedule/enabled changes restart the clock. + const schedule: ScheduledTask["schedule"] = + input.schedule.type === "webhook" + ? { + type: "webhook", + signature: + input.schedule.signature == null + ? null + : { + header: input.schedule.signature.header.toLowerCase(), + encoding: input.schedule.signature.encoding, + prefix: input.schedule.signature.prefix, + }, + maxDeliveryAgeMinutes: input.schedule.maxDeliveryAgeMinutes ?? null, + ...(input.schedule.dedupe === undefined + ? existingTask?.schedule.type === "webhook" && + existingTask.schedule.dedupe !== undefined + ? { dedupe: existingTask.schedule.dedupe } + : {} + : { dedupe: input.schedule.dedupe }), + ...webhookFilters( + // Omitted keeps the stored filters, so a client that predates + // them can re-save a task without clearing them. + input.schedule.filters ?? + (existingTask?.schedule.type === "webhook" + ? existingTask.schedule.filters + : undefined), + ), + } + : input.schedule; + const webhook = + input.schedule.type === "webhook" + ? yield* Effect.gen(function* () { + const existing = existingTask === null ? null : yield* findWebhookCredentials(id); + const token = existing?.token ?? (yield* newWebhookToken); + const signature = input.schedule.type === "webhook" ? input.schedule.signature : null; + // A secretRef is a value the user entered for an agent; this + // save consumes it, so it cannot be used again. A replay of a + // save that already used it keeps the secret that save stored. + const replay = input.commandId !== undefined && existing?.secret != null; + const fromRef = + signature?.secretRef === undefined + ? undefined + : yield* secretRequests + .consume({ ref: signature.secretRef, projectId: input.projectId }) + .pipe( + Effect.catch((error) => + replay + ? Effect.succeed(undefined) + : Effect.fail(taskError(error.message, { taskId: id })), + ), + ); + // A ref, when given, is the only source: a plain secret sent + // alongside it must not replace what a replayed save stored. + const provided = signature?.secretRef === undefined ? signature?.secret : fromRef; + const secret = signature == null ? null : (provided ?? existing?.secret ?? null); + const secretChanged = + signature == null || provided !== undefined || existing === null; + if (signature != null && secret === null) { + return yield* taskError("A webhook signature check needs a signing secret.", { + taskId: id, + }); } - : input.schedule; - const webhook = - input.schedule.type === "webhook" - ? yield* Effect.gen(function* () { - const existing = existingTask === null ? null : yield* findWebhookCredentials(id); - const token = existing?.token ?? (yield* newWebhookToken); - const signature = - input.schedule.type === "webhook" ? input.schedule.signature : null; - // A secretRef is a value the user entered for an agent; this - // save consumes it, so it cannot be used again. A replay of a - // save that already used it keeps the secret that save stored. - const replay = input.commandId !== undefined && existing?.secret != null; - const fromRef = - signature?.secretRef === undefined - ? undefined - : yield* secretRequests - .consume({ ref: signature.secretRef, projectId: input.projectId }) - .pipe( - Effect.catch((error) => - replay - ? Effect.succeed(undefined) - : Effect.fail(taskError(error.message, { taskId: id })), - ), - ); - // A ref, when given, is the only source: a plain secret sent - // alongside it must not replace what a replayed save stored. - const provided = signature?.secretRef === undefined ? signature?.secret : fromRef; - const secret = signature == null ? null : (provided ?? existing?.secret ?? null); - const secretChanged = - signature == null || provided !== undefined || existing === null; - if (signature != null && secret === null) { - return yield* taskError("A webhook signature check needs a signing secret.", { - taskId: id, - }); - } - return { token, secret, secretChanged }; - }) - : { token: null, secret: null, secretChanged: true }; - const scheduleUnchanged = - existingTask !== null && - existingTask.enabled === input.enabled && - isSameSchedule(existingTask.schedule, schedule); - const task: ScheduledTask = { - id, - title: input.title, - prompt: input.prompt, - enabled: input.enabled, - schedule, - projectId: input.projectId, - threadId: input.threadId ?? null, - workspaceStrategy: input.workspaceStrategy, - modelSelection: input.modelSelection, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - createdBy: existingTask?.createdBy ?? input.createdBy ?? "user", - creationSource: input.creationSource ?? "web", - createdAt: existingTask?.createdAt ?? iso(now), - updatedAt: iso(now), - nextRunAt: scheduleUnchanged - ? existingTask.nextRunAt - : nextRunAt({ enabled: input.enabled, schedule }, now), - lastRunAt: existingTask?.lastRunAt ?? null, - lastRunStatus: existingTask?.lastRunStatus ?? "never", - lastRunError: existingTask?.lastRunError ?? null, - runCount: existingTask?.runCount ?? 0, - }; - yield* saveTask(task, input.requireExisting === true, webhook); - yield* notifyChanged; - return { task: (yield* findTask(id)) ?? task }; - }); + return { token, secret, secretChanged }; + }) + : { token: null, secret: null, secretChanged: true }; + const workspaceStrategy: ScheduledTask["workspaceStrategy"] = + input.workspaceStrategy.type === "worktree" && + (yield* managedFolders.isScratchProject(input.projectId)) + ? { type: "root" } + : input.workspaceStrategy; + const scheduleUnchanged = + existingTask !== null && + existingTask.enabled === input.enabled && + isSameSchedule(existingTask.schedule, schedule); + const task: ScheduledTask = { + id, + title: input.title, + prompt: input.prompt, + enabled: input.enabled, + schedule, + projectId: input.projectId, + threadId: input.threadId ?? null, + workspaceStrategy, + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + createdBy: existingTask?.createdBy ?? input.createdBy ?? "user", + creationSource: input.creationSource ?? "web", + createdAt: existingTask?.createdAt ?? iso(now), + updatedAt: iso(now), + nextRunAt: scheduleUnchanged + ? existingTask.nextRunAt + : nextRunAt({ enabled: input.enabled, schedule }, now), + lastRunAt: existingTask?.lastRunAt ?? null, + lastRunStatus: existingTask?.lastRunStatus ?? "never", + lastRunError: existingTask?.lastRunError ?? null, + runCount: existingTask?.runCount ?? 0, + }; + yield* saveTask(task, input.requireExisting === true, webhook); + yield* notifyChanged; + return { task: (yield* findTask(id)) ?? task }; + }); - const setEnabled: ScheduledTaskService["Service"]["setEnabled"] = (input) => - Effect.gen(function* () { - const existing = yield* loadTask(input.id); - if (existing.enabled === input.enabled) return { task: existing }; - const now = yield* localNow; - const next = nextRunAt({ enabled: input.enabled, schedule: existing.schedule }, now); - // RETURNING so a task deleted between the load and this UPDATE is a - // visible not-found error, not a false success. - const updated = yield* sql<{ task_id: string }>` + const setEnabled: ScheduledTaskService["Service"]["setEnabled"] = Effect.fn( + "ScheduledTaskService.setEnabled", + )(function* (input) { + const existing = yield* loadTask(input.id); + if (existing.enabled === input.enabled) return { task: existing }; + const now = yield* localNow; + const next = nextRunAt({ enabled: input.enabled, schedule: existing.schedule }, now); + // RETURNING so a task deleted between the load and this UPDATE is a + // visible not-found error, not a false success. + const updated = yield* sql<{ task_id: string }>` UPDATE scheduled_tasks SET enabled = ${input.enabled ? 1 : 0}, next_run_at = ${next}, @@ -1059,81 +1202,129 @@ export const layer = Layer.effect( WHERE task_id = ${input.id} RETURNING task_id `.pipe( - Effect.mapError((cause) => - taskError("Could not update schedule task.", { taskId: input.id, cause }), - ), - ); - if (updated.length === 0) { - return yield* taskError("Schedule task not found.", { taskId: input.id }); - } - yield* notifyChanged; - return { - task: { ...existing, enabled: input.enabled, nextRunAt: next, updatedAt: iso(now) }, - }; - }); + Effect.mapError((cause) => + taskError("Could not update schedule task.", { taskId: input.id, cause }), + ), + ); + if (updated.length === 0) { + return yield* taskError("Schedule task not found.", { taskId: input.id }); + } + yield* notifyChanged; + return { + task: { ...existing, enabled: input.enabled, nextRunAt: next, updatedAt: iso(now) }, + }; + }); + + const forgetWebhookTask = (id: ScheduledTaskId) => + Effect.all( + [ + Ref.update(webhookRateWindows, (windows) => { + const next = new Map(windows); + next.delete(id); + return next; + }), + Ref.update(webhookRejectedRateWindows, (windows) => { + const next = new Map(windows); + next.delete(id); + return next; + }), + Ref.update(webhookDedupeLogWindows, (windows) => { + const next = new Map(windows); + next.delete(id); + return next; + }), + Ref.update(webhookFilteredLogWindows, (windows) => { + const next = new Map(windows); + next.delete(id); + return next; + }), + Ref.update(webhookPermits, (permits) => { + const next = new Map(permits); + next.delete(id); + return next; + }), + ], + { discard: true }, + ); const deleteTask: ScheduledTaskService["Service"]["delete"] = (input) => deleteRow(input.id).pipe( - Effect.andThen( - Effect.all([ - Ref.update(webhookRateWindows, (windows) => { - const next = new Map(windows); - next.delete(input.id); - return next; - }), - Ref.update(webhookPermits, (permits) => { - const next = new Map(permits); - next.delete(input.id); - return next; - }), - ]), - ), + Effect.andThen(forgetWebhookTask(input.id)), Effect.andThen(notifyChanged), Effect.as({ id: input.id }), ); - const runNow: ScheduledTaskService["Service"]["runNow"] = (input: ScheduledTaskRunNowInput) => - Effect.gen(function* () { - const task = yield* loadTask(input.id); - if (task.schedule.type === "webhook") { - return yield* taskError("Webhook tasks run when their URL receives a request.", { - taskId: input.id, - }); - } - const next = yield* runTask(task, "manual").pipe( + const deleteForThread = Effect.fn("ScheduledTaskService.deleteForThread")(function* ( + threadId: ThreadId, + ) { + // Check the current binding in the transaction: an automation may have + // been unbound or moved since thread deletion queued this cleanup. + const removed = yield* sql + .withTransaction( + Effect.gen(function* () { + yield* sql` + DELETE FROM scheduled_task_webhook_deliveries + WHERE task_id IN (SELECT task_id FROM scheduled_tasks WHERE thread_id = ${threadId}) + `; + return yield* sql<{ task_id: ScheduledTaskId }>` + DELETE FROM scheduled_tasks WHERE thread_id = ${threadId} RETURNING task_id + `; + }), + ) + .pipe( Effect.mapError((cause) => - taskError("Could not run schedule task.", { taskId: input.id, cause }), + taskError("Could not delete thread's schedule tasks.", { cause }), ), ); - return { task: next }; + yield* Effect.forEach(removed, ({ task_id }) => forgetWebhookTask(task_id), { + discard: true, }); + if (removed.length > 0) yield* notifyChanged; + }); - const rotateWebhookToken: ScheduledTaskService["Service"]["rotateWebhookToken"] = (input) => - Effect.gen(function* () { - const task = yield* loadTask(input.id); - if (task.schedule.type !== "webhook") { - return yield* taskError("Only webhook tasks have a URL token.", { taskId: input.id }); - } - const token = yield* newWebhookToken; - const now = yield* localNow; - // Matching created_at keeps a rotation from landing on a task deleted - // and recreated under the same id since it was loaded. - const updated = yield* sql<{ task_id: string }>` + const runNow: ScheduledTaskService["Service"]["runNow"] = Effect.fn( + "ScheduledTaskService.runNow", + )(function* (input: ScheduledTaskRunNowInput) { + const task = yield* loadTask(input.id); + if (task.schedule.type === "webhook") { + return yield* taskError("Webhook tasks run when their URL receives a request.", { + taskId: input.id, + }); + } + return yield* runTask(task, "manual").pipe( + Effect.mapError((cause) => + taskError("Could not run schedule task.", { taskId: input.id, cause }), + ), + ); + }); + + const rotateWebhookToken: ScheduledTaskService["Service"]["rotateWebhookToken"] = Effect.fn( + "ScheduledTaskService.rotateWebhookToken", + )(function* (input) { + const task = yield* loadTask(input.id); + if (task.schedule.type !== "webhook") { + return yield* taskError("Only webhook tasks have a URL token.", { taskId: input.id }); + } + const token = yield* newWebhookToken; + const now = yield* localNow; + // Matching created_at keeps a rotation from landing on a task deleted + // and recreated under the same id since it was loaded. + const updated = yield* sql<{ task_id: string }>` UPDATE scheduled_tasks SET webhook_token = ${token}, updated_at = ${iso(now)} WHERE task_id = ${input.id} AND created_at = ${task.createdAt} RETURNING task_id `.pipe( - Effect.mapError((cause) => - taskError("Could not rotate webhook token.", { taskId: input.id, cause }), - ), - ); - if (updated.length === 0) { - return yield* taskError("Schedule task was deleted or replaced.", { taskId: input.id }); - } - yield* notifyChanged; - return { task: yield* loadTask(input.id) }; - }); + Effect.mapError((cause) => + taskError("Could not rotate webhook token.", { taskId: input.id, cause }), + ), + ); + if (updated.length === 0) { + return yield* taskError("Schedule task was deleted or replaced.", { taskId: input.id }); + } + yield* notifyChanged; + return { task: yield* loadTask(input.id) }; + }); const deliveryHeaders = (row: WebhookDeliveryRow): Readonly> => Option.getOrElse(decodeHeadersJson(row.headers_json), () => ({})); @@ -1146,6 +1337,7 @@ export const layer = Layer.effect( bodyBytes: row.body_bytes, outcome: row.outcome as ScheduledTaskWebhookDeliveryOutcome, signatureVerified: row.signature_verified === 1, + pendingWindow: row.outcome === "accepted" && row.pending_window === 1, missingFields: Option.getOrElse(decodeMissingFieldsJson(row.missing_fields_json), () => []), error: row.error, }); @@ -1154,41 +1346,71 @@ export const layer = Layer.effect( input, ) => sql` - SELECT * FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.id} + SELECT d.*, EXISTS (SELECT 1 FROM scheduled_task_webhook_pending p + WHERE p.delivery_id = d.delivery_id AND p.state IN ('pending', 'dispatching')) AS pending_window + FROM scheduled_task_webhook_deliveries d WHERE task_id = ${input.id} ORDER BY received_at DESC, rowid DESC `.pipe( - Effect.map((rows) => ({ deliveries: rows.map(decodeDeliverySummary) })), + Effect.map((rows) => ({ + deliveries: rows + .filter( + (row) => input.acceptAnyOutcome === true || LEGACY_DELIVERY_OUTCOMES.has(row.outcome), + ) + .map(decodeDeliverySummary), + })), Effect.mapError((cause) => taskError("Could not list webhook deliveries.", { taskId: input.id, cause }), ), ); - const getWebhookDelivery: ScheduledTaskService["Service"]["getWebhookDelivery"] = (input) => - Effect.gen(function* () { - const rows = yield* sql` - SELECT * FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.id} AND delivery_id = ${input.deliveryId} + const getWebhookDelivery: ScheduledTaskService["Service"]["getWebhookDelivery"] = Effect.fn( + "ScheduledTaskService.getWebhookDelivery", + )(function* (input) { + const rows = yield* sql` + SELECT d.*, EXISTS (SELECT 1 FROM scheduled_task_webhook_pending p + WHERE p.delivery_id = d.delivery_id AND p.state IN ('pending', 'dispatching')) AS pending_window + FROM scheduled_task_webhook_deliveries d WHERE task_id = ${input.id} AND delivery_id = ${input.deliveryId} `.pipe( - Effect.mapError((cause) => - taskError("Could not load webhook delivery.", { taskId: input.id, cause }), - ), - ); - const row = rows[0]; - if (row === undefined) { - return yield* taskError("Webhook delivery not found.", { taskId: input.id }); - } - return { - delivery: { - ...decodeDeliverySummary(row), - query: row.query, - headers: deliveryHeaders(row), - body: row.body, - bodyTruncated: row.body_truncated === 1, - renderedPrompt: row.rendered_prompt, - }, - }; - }); + Effect.mapError((cause) => + taskError("Could not load webhook delivery.", { taskId: input.id, cause }), + ), + ); + const row = rows[0]; + if ( + row === undefined || + (input.acceptAnyOutcome !== true && !LEGACY_DELIVERY_OUTCOMES.has(row.outcome)) + ) { + return yield* taskError("Webhook delivery not found.", { taskId: input.id }); + } + return { + delivery: { + ...decodeDeliverySummary(row), + query: row.query, + headers: deliveryHeaders(row), + body: row.body, + bodyTruncated: row.body_truncated === 1, + renderedPrompt: row.rendered_prompt, + }, + }; + }); + + const deliveryRequestBody = (request: WebhookTriggerRequest) => { + const truncated = request.body.byteLength > WEBHOOK_DELIVERY_LOG_BODY_LIMIT; + return { + truncated, + body: truncated + ? new TextDecoder().decode(request.body.subarray(0, WEBHOOK_DELIVERY_LOG_BODY_LIMIT)) + : request.bodyText, + }; + }; + const replaceDeliveryRequest = (deliveryId: string, request: WebhookTriggerRequest) => { + const logged = deliveryRequestBody(request); + return sql`UPDATE scheduled_task_webhook_deliveries SET + method = ${request.method}, query = ${redactQuery(request.query)}, + headers_json = ${encodeHeadersJson(redactHeaders(request.headers))}, body = ${logged.body}, + body_bytes = ${request.body.byteLength}, body_truncated = ${logged.truncated ? 1 : 0} + WHERE delivery_id = ${deliveryId}`.pipe(Effect.asVoid, Effect.mapError(dedupeError)); + }; const recordDelivery = (input: { readonly id: string; @@ -1201,10 +1423,7 @@ export const layer = Layer.effect( readonly renderedPrompt: string | null; readonly error?: string; }) => { - const truncated = input.request.body.byteLength > WEBHOOK_DELIVERY_LOG_BODY_LIMIT; - const loggedBody = truncated - ? new TextDecoder().decode(input.request.body.subarray(0, WEBHOOK_DELIVERY_LOG_BODY_LIMIT)) - : input.request.bodyText; + const { truncated, body: loggedBody } = deliveryRequestBody(input.request); return sql .withTransaction( Effect.gen(function* () { @@ -1235,15 +1454,28 @@ export const layer = Layer.effect( rendered_prompt = excluded.rendered_prompt, error = excluded.error `; + // Each suppression bucket retains its own rows, never real run history. + const bucket = (outcome: string, error: string | undefined) => + outcome === "filtered" + ? "filtered" + : outcome === "duplicate" || + outcome === "coalesced" || + outcome === "rate_limited" || + error === PROMPT_TOO_LONG + ? "dedupe" + : "main"; + const retentionBucket = bucket(input.outcome, input.error); + const rowBucket = sql.literal(`CASE WHEN outcome = 'filtered' THEN 'filtered' + WHEN outcome IN ('duplicate', 'coalesced', 'rate_limited') OR error = 'The filled-in prompt is too long.' THEN 'dedupe' + ELSE 'main' END`); yield* sql` DELETE FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.taskId} + WHERE task_id = ${input.taskId} AND ${rowBucket} = ${retentionBucket} AND delivery_id NOT IN ( SELECT delivery_id FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.taskId} - -- rowid breaks timestamp ties in arrival order; delivery ids are random. + WHERE task_id = ${input.taskId} AND ${rowBucket} = ${retentionBucket} ORDER BY received_at DESC, rowid DESC - LIMIT ${WEBHOOK_DELIVERY_RETENTION} + LIMIT ${retentionBucket === "main" ? WEBHOOK_DELIVERY_RETENTION : retentionBucket === "filtered" ? WEBHOOK_FILTERED_DELIVERY_RETENTION : WEBHOOK_DEDUPE_DELIVERY_RETENTION} ) `; }), @@ -1262,13 +1494,15 @@ export const layer = Layer.effect( WHERE delivery_id = ${deliveryId} `.pipe(Effect.ignore); - /** - * Sliding one-minute window counting every request with a valid token, - * including ones the signature check later rejects. - */ - const takeRateSlot = (id: ScheduledTaskId, nowMs: number) => + /** Sliding one-minute window for deliveries that can start work. */ + const takeRateSlot = ( + id: ScheduledTaskId, + nowMs: number, + rateWindows = webhookRateWindows, + reservation = { at: nowMs }, + ) => Ref.modify( - webhookRateWindows, + rateWindows, ( windows, ): readonly [ @@ -1276,7 +1510,7 @@ export const layer = Layer.effect( ReadonlyMap, ] => { const current = windows.get(id); - const recent = (current?.accepted ?? []).filter((at) => nowMs - at < 60_000); + const recent = (current?.accepted ?? []).filter((entry) => nowMs - entry.at < 60_000); if (recent.length >= WEBHOOK_RATE_LIMIT_PER_MINUTE) { const first = current?.rejectedLogged !== true; return [ @@ -1286,48 +1520,150 @@ export const layer = Layer.effect( } return [ "allowed", - new Map(windows).set(id, { accepted: [...recent, nowMs], rejectedLogged: false }), + new Map(windows).set(id, { accepted: [...recent, reservation], rejectedLogged: false }), ]; }, ); - const webhookPermit = (id: ScheduledTaskId) => - Effect.gen(function* () { - const existing = (yield* Ref.get(webhookPermits)).get(id); - if (existing !== undefined) return existing; - const created = yield* Semaphore.make(1); - return yield* Ref.modify(webhookPermits, (permits) => { - const raced = permits.get(id); - return raced === undefined - ? [created, new Map(permits).set(id, created)] - : [raced, permits]; - }); + const takeDedupeLogSlot = (id: ScheduledTaskId, nowMs: number) => + Ref.modify(webhookDedupeLogWindows, (windows) => { + const recent = (windows.get(id) ?? []).filter((at) => nowMs - at < 60_000); + return recent.length >= WEBHOOK_DEDUPE_LOG_PER_MINUTE + ? ([false, windows] as const) + : ([true, new Map(windows).set(id, [...recent, nowMs])] as const); + }); + const releaseReplay = (deliveryId: string) => + releaseWebhookReplay(deliveryId).pipe( + Effect.provideService(SqlClient.SqlClient, sql), + Effect.mapError(dedupeError), + ); + /** Sliding one-minute window of filtered requests written to the delivery log. */ + const takeFilteredLogSlot = (id: ScheduledTaskId, nowMs: number) => + Ref.modify( + webhookFilteredLogWindows, + (windows): readonly [boolean, ReadonlyMap>] => { + const recent = (windows.get(id) ?? []).filter((at) => nowMs - at < 60_000); + return recent.length >= WEBHOOK_FILTERED_LOG_PER_MINUTE + ? [false, windows] + : [true, new Map(windows).set(id, [...recent, nowMs])]; + }, + ); + + const webhookPermit = Effect.fn("ScheduledTaskService.webhookPermit")(function* ( + id: ScheduledTaskId, + ) { + const existing = (yield* Ref.get(webhookPermits)).get(id); + if (existing !== undefined) return existing; + const created = yield* Semaphore.make(1); + return yield* Ref.modify(webhookPermits, (permits) => { + const raced = permits.get(id); + return raced === undefined + ? [created, new Map(permits).set(id, created)] + : [raced, permits]; }); + }); const serviceScope = yield* Effect.scope; - const observeDelivery = ( + const releaseFailedDelivery = Effect.fn("ScheduledTaskService.releaseFailedDelivery")( + function* (deliveryId: string) { + yield* releaseReplay(deliveryId); + yield* sql`DELETE FROM scheduled_task_webhook_relay_deliveries WHERE relay_delivery_id IN ( + SELECT substr(value, ${"delivery:relay:".length + 1}) FROM scheduled_task_webhook_pending, json_each(delivery_ids_json) + WHERE delivery_id = ${deliveryId} AND value LIKE 'delivery:relay:%' + )`.pipe(Effect.mapError(dedupeError)); + if (deliveryId.startsWith("delivery:relay:")) + yield* sql`DELETE FROM scheduled_task_webhook_relay_deliveries WHERE relay_delivery_id = ${deliveryId.slice("delivery:relay:".length)}`.pipe( + Effect.mapError(dedupeError), + ); + }, + (effect) => sql.withTransaction(effect).pipe(Effect.mapError(dedupeError)), + ); + + const runWebhookDelivery = Effect.fn("ScheduledTaskService.runWebhookDelivery")(function* ( + task: ScheduledTask, + deliveryId: string, + prompt: string, + ) { + const runOutcome = (outcome: "started" | "skipped" | "failed") => + Effect.all([ + Effect.annotateCurrentSpan({ "scheduled_task.webhook.run_outcome": outcome }), + Metrics.increment(Metrics.webhookRunsTotal, { outcome }), + ]); + const dispatched = yield* Ref.make(false); + return yield* runTask(task, "webhook", { deliveryId, prompt, dispatched }).pipe( + Effect.flatMap(({ task: completed }) => + completed.lastRunStatus === "failed" + ? runOutcome("failed").pipe( + Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), + Effect.andThen(releaseFailedDelivery(deliveryId)), + ) + : runOutcome("started"), + ), + Effect.catchTags({ + WebhookDeliverySkipped: (skipped) => + runOutcome("skipped").pipe( + Effect.andThen(markDeliveryFailed(deliveryId, skipped.reason)), + Effect.andThen(releaseFailedDelivery(deliveryId)), + ), + }), + Effect.as(true), + // Dispatch success owns the replay key even if later bookkeeping fails. + Effect.catchCause((cause) => + Effect.gen(function* () { + const started = yield* Ref.get(dispatched); + yield* Effect.logWarning("Webhook dispatch failed", { taskId: task.id, cause }); + yield* runOutcome(started ? "started" : "failed"); + yield* markDeliveryFailed( + deliveryId, + started + ? "The run started, but completion could not be recorded." + : "The run failed to start.", + ); + if (!started) { + yield* sql + .withTransaction( + Effect.gen(function* () { + const counted = + yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'failed' + WHERE delivery_id = ${deliveryId} AND state = 'running' RETURNING delivery_id`; + if (counted.length > 0) + yield* sql`UPDATE scheduled_tasks SET run_count = run_count + 1 + WHERE task_id = ${task.id} AND created_at = ${task.createdAt}`; + }), + ) + .pipe(Effect.mapError(dedupeError)); + yield* releaseFailedDelivery(deliveryId); + } + // Pending windows retry the same command id to finish bookkeeping. + return !started; + }), + ), + ); + }); + + // Delivery attributes belong to the enclosing triggerWebhook span. + const observeDelivery = Effect.fnUntraced(function* ( request: WebhookTriggerRequest, outcome: WebhookDeliveryOutcome, receivedAt: DateTime.DateTime | undefined, now: DateTime.DateTime | undefined, - ) => - Effect.gen(function* () { - const source = request.relayDeliveryId === undefined ? "direct" : "relay"; - yield* Effect.annotateCurrentSpan({ - "scheduled_task.webhook.outcome": outcome, - "scheduled_task.webhook.source": source, - }); - yield* Metrics.increment(Metrics.webhookDeliveriesTotal, { outcome, source }); - if (request.receivedAt !== undefined && receivedAt !== undefined && now !== undefined) { - const heldMs = Math.max( - 0, - DateTime.toEpochMillis(now) - DateTime.toEpochMillis(receivedAt), - ); - yield* Effect.annotateCurrentSpan({ "scheduled_task.webhook.held_ms": heldMs }); - yield* Metric.update(Metrics.webhookHeldDelay, Duration.millis(heldMs)); - } + ) { + const source = request.relayDeliveryId === undefined ? "direct" : "relay"; + yield* Effect.annotateCurrentSpan({ + "scheduled_task.webhook.outcome": outcome, + "scheduled_task.webhook.source": source, }); + yield* Metrics.increment(Metrics.webhookDeliveriesTotal, { outcome, source }); + if (request.receivedAt !== undefined && receivedAt !== undefined && now !== undefined) { + const heldMs = Math.max( + 0, + DateTime.toEpochMillis(now) - DateTime.toEpochMillis(receivedAt), + ); + yield* Effect.annotateCurrentSpan({ "scheduled_task.webhook.held_ms": heldMs }); + yield* Metric.update(Metrics.webhookHeldDelay, Duration.millis(heldMs)); + } + }); const triggerWebhook: ScheduledTaskService["Service"]["triggerWebhook"] = (request) => triggerWebhookUnobserved(request).pipe( @@ -1343,248 +1679,476 @@ export const layer = Layer.effect( }), ); - const triggerWebhookUnobserved = (request: WebhookTriggerRequest) => - Effect.gen(function* () { - const taskId = decodeTaskId(request.hookId); - const notFound = observeDelivery(request, "not_found", undefined, undefined).pipe( - Effect.as({ _tag: "not_found" as const }), - ); - if (Option.isNone(taskId)) return yield* notFound; - const rows = yield* getRows(taskId.value).pipe( - Effect.mapError((cause) => - taskError("Could not load schedule task.", { taskId: taskId.value, cause }), - ), - ); - const row = rows[0]; - // A wrong token is indistinguishable from an unknown hook, so the URL - // does not reveal which hooks exist. - if ( - row === undefined || - row.webhook_token === null || - !constantTimeEquals(request.token, row.webhook_token) - ) { - return yield* notFound; - } - const task = yield* decodeRow(row); - yield* Effect.annotateCurrentSpan({ "scheduled_task.id": task.id }); - const schedule = task.schedule; - if (schedule.type !== "webhook") return yield* notFound; + // triggerWebhook owns the operation span and its task-id annotation. + const triggerWebhookUnobserved = Effect.fnUntraced(function* (request: WebhookTriggerRequest) { + const taskId = decodeTaskId(request.hookId); + const notFound = observeDelivery(request, "not_found", undefined, undefined).pipe( + Effect.as({ _tag: "not_found" as const }), + ); + if (Option.isNone(taskId)) return yield* notFound; + const rows = yield* getRows(taskId.value).pipe( + Effect.mapError((cause) => + taskError("Could not load schedule task.", { taskId: taskId.value, cause }), + ), + ); + const row = rows[0]; + // A wrong token is indistinguishable from an unknown hook, so the URL + // does not reveal which hooks exist. + if ( + row === undefined || + row.webhook_token === null || + !constantTimeEquals(request.token, row.webhook_token) + ) { + return yield* notFound; + } + const task = yield* decodeRow(row); + yield* Effect.annotateCurrentSpan({ "scheduled_task.id": task.id }); + const schedule = task.schedule; + if (schedule.type !== "webhook") return yield* notFound; - const now = yield* localNow; - // Anyone can reach the tunnel directly and set the relay's header, so - // a receive time is never later than now: a future one would pin the - // delivery in the log and slip past the task's max age. - const receivedAt = - request.receivedAt === undefined - ? now - : DateTime.min( - Option.getOrElse(DateTime.make(request.receivedAt), () => now), - now, - ); - const observe = (outcome: WebhookDeliveryOutcome) => - observeDelivery(request, outcome, receivedAt, now); - const deliveryId = ScheduledTaskWebhookDeliveryId.make( - request.relayDeliveryId === undefined - ? `delivery:${yield* crypto - .randomUUIDv4() - .pipe( - Effect.mapError((cause) => - taskError("Could not generate delivery id.", { cause }), - ), - )}` - : `delivery:relay:${request.relayDeliveryId}`, - ); - // Claimed in its own table, kept longer than the relay holds a request, - // because the delivery log is trimmed. A rate-limited delivery stays - // held on the relay, so it must give up its claim or the next pass - // would treat it as already delivered. - const releaseClaim = (result: A) => - request.relayDeliveryId === undefined - ? Effect.succeed(result) - : sql` + const now = yield* localNow; + // Anyone can reach the tunnel directly and set the relay's header, so + // a receive time is never later than now: a future one would pin the + // delivery in the log and slip past the task's max age. + const receivedAt = + request.receivedAt === undefined + ? now + : DateTime.min( + Option.getOrElse(DateTime.make(request.receivedAt), () => now), + now, + ); + const observe = (outcome: WebhookDeliveryOutcome) => + observeDelivery(request, outcome, receivedAt, now); + const deliveryId = ScheduledTaskWebhookDeliveryId.make( + request.relayDeliveryId === undefined + ? `delivery:${yield* crypto + .randomUUIDv4() + .pipe( + Effect.mapError((cause) => taskError("Could not generate delivery id.", { cause })), + )}` + : `delivery:relay:${request.relayDeliveryId}`, + ); + // Claimed in its own table, kept longer than the relay holds a request, + // because the delivery log is trimmed. A rate-limited delivery stays + // held on the relay, so it must give up its claim or the next pass + // would treat it as already delivered. + let claimAcquired = false; + const releaseClaim = (result: A) => + !claimAcquired || request.relayDeliveryId === undefined + ? Effect.succeed(result) + : sql` DELETE FROM scheduled_task_webhook_relay_deliveries WHERE relay_delivery_id = ${request.relayDeliveryId} `.pipe(Effect.ignore, Effect.as(result)); - // From the claim until the run is forked nothing may interrupt: a - // request dropped in between (the relay hangs up after its timeout) - // would leave a claimed delivery that never runs, or a queue slot that - // is never released. - return yield* Effect.uninterruptible( - Effect.gen(function* () { - if (request.relayDeliveryId !== undefined) { - const claimed = yield* sql<{ relay_delivery_id: string }>` + // From the claim until the run is forked nothing may interrupt: a + // request dropped in between (the relay hangs up after its timeout) + // would leave a claimed delivery that never runs, or a queue slot that + // is never released. + return yield* Effect.uninterruptible( + Effect.gen(function* () { + if (request.relayDeliveryId !== undefined) { + const claimed = yield* sql<{ relay_delivery_id: string }>` INSERT INTO scheduled_task_webhook_relay_deliveries (relay_delivery_id, task_id, seen_at) VALUES (${request.relayDeliveryId}, ${task.id}, ${iso(now)}) ON CONFLICT (relay_delivery_id) DO NOTHING RETURNING relay_delivery_id `.pipe( - Effect.mapError((cause) => - taskError("Could not record webhook delivery.", { taskId: task.id, cause }), - ), - ); - if (claimed.length === 0) { - yield* observe("duplicate"); - return { _tag: "accepted" as const, deliveryId, outcome: "duplicate" as const }; - } - yield* sql` + Effect.mapError((cause) => + taskError("Could not record webhook delivery.", { taskId: task.id, cause }), + ), + ); + if (claimed.length === 0) { + yield* observe("duplicate"); + return { _tag: "accepted" as const, deliveryId, outcome: "duplicate" as const }; + } + claimAcquired = true; + yield* sql` DELETE FROM scheduled_task_webhook_relay_deliveries WHERE seen_at < ${iso(DateTime.subtract(now, { hours: 48 }))} `.pipe(Effect.ignore); - } - const log = ( - outcome: ScheduledTaskWebhookDeliveryOutcome, - details: { - readonly signatureVerified?: boolean; - readonly missing?: ReadonlyArray; - readonly renderedPrompt?: string; - readonly error?: string; - } = {}, - ) => - recordDelivery({ - id: deliveryId, - taskId: task.id, - receivedAt: iso(receivedAt), - request, - outcome, - signatureVerified: details.signatureVerified ?? false, - missing: details.missing ?? [], - renderedPrompt: details.renderedPrompt ?? null, - ...(details.error === undefined ? {} : { error: details.error }), - }); + } + const log = ( + outcome: ScheduledTaskWebhookDeliveryOutcome, + details: { + readonly signatureVerified?: boolean; + readonly missing?: ReadonlyArray; + readonly renderedPrompt?: string; + readonly error?: string; + } = {}, + ) => + recordDelivery({ + id: deliveryId, + taskId: task.id, + receivedAt: iso(receivedAt), + request, + outcome, + signatureVerified: details.signatureVerified ?? false, + missing: details.missing ?? [], + renderedPrompt: details.renderedPrompt ?? null, + ...(details.error === undefined ? {} : { error: details.error }), + }); - // Only the first rejected request in a window is logged, so a flood - // cannot write rows or push the real deliveries out of the log. - const slot = yield* takeRateSlot(task.id, DateTime.toEpochMillis(now)); - if (slot !== "allowed") { - if (slot === "first_rejected") yield* log("rate_limited"); - yield* observe("rate_limited"); - return yield* releaseClaim({ - _tag: "rate_limited" as const, - outcome: "rate_limited" as const, + // Reject floods before signature work or rejection-log writes; valid + // deliveries give this reservation back once validation succeeds. + const rejectionReservation = { at: DateTime.toEpochMillis(now) }; + const rejectionSlot = yield* takeRateSlot( + task.id, + DateTime.toEpochMillis(now), + webhookRejectedRateWindows, + rejectionReservation, + ); + if (rejectionSlot !== "allowed") { + if (rejectionSlot === "first_rejected") yield* log("rate_limited"); + yield* observe("rate_limited"); + return yield* releaseClaim({ _tag: "rate_limited", outcome: "rate_limited" } as const); + } + if (!task.enabled) { + yield* log("disabled"); + yield* observe("disabled"); + return { _tag: "disabled" as const }; + } + const signature = schedule.signature; + if (signature !== null) { + const verified = + row.webhook_secret !== null && + verifyWebhookSignature({ + signature, + secret: row.webhook_secret, + headers: request.headers, + body: request.body, }); + if (!verified) { + yield* log("rejected_signature"); + yield* observe("rejected_signature"); + return { _tag: "rejected_signature" as const }; } - if (!task.enabled) { - yield* log("disabled"); - yield* observe("disabled"); - return { _tag: "disabled" as const }; - } - const signature = schedule.signature; - if (signature !== null) { - const verified = - row.webhook_secret !== null && - verifyWebhookSignature({ - signature, - secret: row.webhook_secret, - headers: request.headers, - body: request.body, - }); - if (!verified) { - yield* log("rejected_signature"); - yield* observe("rejected_signature"); - return { _tag: "rejected_signature" as const }; - } - } - const maxAgeMinutes = schedule.maxDeliveryAgeMinutes ?? null; - if ( - maxAgeMinutes !== null && - DateTime.toEpochMillis(now) - DateTime.toEpochMillis(receivedAt) > - maxAgeMinutes * 60_000 - ) { - yield* log("expired", { signatureVerified: signature !== null }); - yield* observe("expired"); - return { _tag: "expired" as const }; - } + } + const maxAgeMinutes = schedule.maxDeliveryAgeMinutes ?? null; + if ( + maxAgeMinutes !== null && + DateTime.toEpochMillis(now) - DateTime.toEpochMillis(receivedAt) > + maxAgeMinutes * 60_000 + ) { + yield* log("expired", { signatureVerified: signature !== null }); + yield* observe("expired"); + return { _tag: "expired" as const }; + } - const frameId = yield* crypto.randomBytes(6).pipe( - Effect.map((bytes) => Buffer.from(bytes).toString("hex")), - Effect.mapError((cause) => taskError("Could not generate frame id.", { cause })), - ); - const rendered = renderWebhookPrompt(task.prompt, request, frameId); - // Providers trim the prompt before checking, so whitespace around - // it is free. - if (rendered.prompt.trim().length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { - yield* log("dispatch_failed", { + const refundRejectionSlot = Ref.update(webhookRejectedRateWindows, (windows) => { + const current = windows.get(task.id); + if (current === undefined) return windows; + return new Map(windows).set(task.id, { + ...current, + accepted: current.accepted.filter((entry) => entry !== rejectionReservation), + }); + }); + + const failedFilter = findFailedWebhookFilter(schedule.filters ?? [], request); + if (failedFilter !== null) { + if (yield* takeFilteredLogSlot(task.id, DateTime.toEpochMillis(now))) { + yield* log("filtered", { signatureVerified: signature !== null, - missing: rendered.missing, - renderedPrompt: rendered.prompt, - error: "The filled-in prompt is too long.", + error: describeFailedWebhookFilter(failedFilter), }); - yield* observe("prompt_too_long"); - return { _tag: "accepted" as const, deliveryId, outcome: "prompt_too_long" as const }; } - // Bound the deliveries one task holds, so steady traffic to a stuck - // task cannot pile up parked fibers. - const queueKey = `${task.id}\u0000${task.createdAt}`; - const queued = yield* Ref.modify(webhookQueued, (counts) => { - const count = counts.get(queueKey) ?? 0; - return count >= WEBHOOK_MAX_QUEUED_PER_TASK - ? ([false, counts] as const) - : ([true, new Map(counts).set(queueKey, count + 1)] as const); + yield* refundRejectionSlot; + yield* observe("filtered"); + return yield* releaseClaim({ + _tag: "accepted" as const, + deliveryId, + outcome: "filtered" as const, }); - if (!queued) { - yield* observe("queue_full"); - return yield* releaseClaim({ - _tag: "rate_limited" as const, - outcome: "queue_full" as const, - }); - } - const release = Ref.update(webhookQueued, (counts) => { + } + + const runWindow = (yield* Ref.get(webhookRateWindows)).get(task.id); + if ( + schedule.dedupe === undefined && + (runWindow?.accepted.filter((entry) => DateTime.toEpochMillis(now) - entry.at < 60_000) + .length ?? 0) >= WEBHOOK_RATE_LIMIT_PER_MINUTE + ) { + if ((yield* takeRateSlot(task.id, DateTime.toEpochMillis(now))) === "first_rejected") + yield* log("rate_limited"); + yield* observe("rate_limited"); + return yield* releaseClaim({ _tag: "rate_limited", outcome: "rate_limited" } as const); + } + const reserve = takeRateSlot(task.id, DateTime.toEpochMillis(now)).pipe( + Effect.flatMap((slot) => (slot === "allowed" ? Effect.void : Effect.fail(slot))), + ); + const queueKey = `${task.id}\u0000${task.createdAt}`; + const checkCapacity = Effect.gen(function* () { + const [row] = yield* sql<{ + count: number; + }>`SELECT COUNT(*) AS count FROM scheduled_task_webhook_pending + WHERE task_id = ${task.id} AND state IN ('pending', 'dispatching', 'running')`; + if ( + (row?.count ?? 0) + ((yield* Ref.get(webhookQueued)).get(queueKey) ?? 0) >= + WEBHOOK_MAX_QUEUED_PER_TASK + ) + return yield* Effect.fail("queue_full" as const); + }).pipe( + Effect.mapError((cause) => (cause === "queue_full" ? cause : dedupeError(cause))), + ); + const admit = (effect: Effect.Effect) => + sql.withTransaction(effect).pipe( + Effect.catch((cause) => + cause === "first_rejected" || cause === "rejected" || cause === "queue_full" + ? Effect.succeed({ + outcome: + cause === "queue_full" + ? ("queue_full" as const) + : ("rate_limited" as const), + log: cause === "first_rejected", + }) + : Effect.fail(isScheduledTaskError(cause) ? cause : dedupeError(cause)), + ), + ); + const deduped = + schedule.dedupe === undefined + ? "run" + : yield* admit( + Effect.gen(function* () { + const outcome = yield* acceptWebhookDedupe( + task, + request, + deliveryId, + DateTime.toEpochMillis(now), + reserve, + takeDedupeLogSlot(task.id, DateTime.toEpochMillis(now)), + checkCapacity, + (ownerId) => replaceDeliveryRequest(ownerId, request), + ).pipe(Effect.provideService(SqlClient.SqlClient, sql)); + if ( + outcome === "pending" || + outcome === "coalesced" || + (outcome === "duplicate" && + (yield* takeDedupeLogSlot(task.id, DateTime.toEpochMillis(now)))) + ) + yield* log(outcome === "pending" ? "accepted" : outcome, { + signatureVerified: signature !== null, + }); + return outcome; + }), + ); + if (typeof deduped !== "string") { + yield* refundRejectionSlot; + if (deduped.log) yield* log("rate_limited"); + yield* observe(deduped.outcome); + return yield* releaseClaim({ _tag: "rate_limited", outcome: deduped.outcome } as const); + } + if (deduped !== "run") { + if (deduped !== "coalesced" && deduped !== "coalesced_unlogged") + yield* refundRejectionSlot; + const outcome = + deduped === "pending" + ? "accepted" + : deduped === "coalesced_unlogged" + ? "coalesced" + : deduped; + yield* observe(outcome); + if (deduped === "duplicate") + return yield* releaseClaim({ _tag: "accepted", deliveryId, outcome } as const); + claimAcquired = false; + return { _tag: "accepted", deliveryId, outcome } as const; + } + const frameId = yield* crypto.randomBytes(6).pipe( + Effect.map((bytes) => Buffer.from(bytes).toString("hex")), + Effect.mapError((cause) => taskError("Could not generate frame id.", { cause })), + ); + const rendered = renderWebhookPrompt(task.prompt, request, frameId); + // Providers trim the prompt before checking, so whitespace around + // it is free. + if (rendered.prompt.trim().length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { + yield* log("dispatch_failed", { + signatureVerified: signature !== null, + missing: rendered.missing, + renderedPrompt: rendered.prompt, + error: PROMPT_TOO_LONG, + }); + yield* releaseReplay(deliveryId); + yield* observe("prompt_too_long"); + return yield* releaseClaim({ + _tag: "accepted" as const, + deliveryId, + outcome: "prompt_too_long" as const, + }); + } + // Bound the deliveries one task holds, so steady traffic to a stuck + // task cannot pile up parked fibers. + yield* refundRejectionSlot; + let queueReserved = false; + const release = Effect.suspend(() => { + if (!queueReserved) return Effect.void; + queueReserved = false; + return Ref.update(webhookQueued, (counts) => { const next = new Map(counts); const count = (next.get(queueKey) ?? 1) - 1; if (count <= 0) next.delete(queueKey); else next.set(queueKey, count); return next; }); - yield* log("accepted", { - signatureVerified: signature !== null, - missing: rendered.missing, - renderedPrompt: rendered.prompt, - }).pipe(Effect.onError(() => release)); - yield* observe("accepted"); - const permit = yield* webhookPermit(task.id); - const runOutcome = (outcome: "started" | "skipped" | "failed") => - Effect.all([ - Effect.annotateCurrentSpan({ "scheduled_task.webhook.run_outcome": outcome }), - Metrics.increment(Metrics.webhookRunsTotal, { outcome }), - ]); - yield* runTask(task, "webhook", { deliveryId, prompt: rendered.prompt }).pipe( - Effect.flatMap((completed) => - completed.lastRunStatus === "failed" - ? runOutcome("failed").pipe( - Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), - ) - : runOutcome("started"), + }); + // Serialize immediate reservations with persisted window admission. + const reserveImmediate = Effect.gen(function* () { + yield* checkCapacity; + yield* reserve; + yield* Ref.update(webhookQueued, (counts) => + new Map(counts).set(queueKey, (counts.get(queueKey) ?? 0) + 1), + ); + queueReserved = true; + }); + const admitted = yield* admit( + Effect.gen(function* () { + const outcome = yield* acceptImmediateWebhookDedupe( + task, + request, + deliveryId, + DateTime.toEpochMillis(now), + reserveImmediate, + ).pipe(Effect.provideService(SqlClient.SqlClient, sql)); + if (outcome === "duplicate") { + if (yield* takeDedupeLogSlot(task.id, DateTime.toEpochMillis(now))) + yield* log("duplicate", { signatureVerified: signature !== null }); + } else + yield* log("accepted", { + signatureVerified: signature !== null, + missing: rendered.missing, + renderedPrompt: rendered.prompt, + }); + return outcome; + }), + ).pipe(Effect.onError(() => release)); + if (typeof admitted !== "string") { + yield* release; + if (admitted.log) yield* log("rate_limited"); + yield* observe(admitted.outcome); + return yield* releaseClaim({ + _tag: "rate_limited", + outcome: admitted.outcome, + } as const); + } + if (admitted === "duplicate") { + yield* release; + yield* observe("duplicate"); + return yield* releaseClaim({ + _tag: "accepted", + deliveryId, + outcome: "duplicate", + } as const); + } + yield* observe("accepted"); + const permit = yield* webhookPermit(task.id); + yield* runWebhookDelivery(task, deliveryId, rendered.prompt).pipe( + permit.withPermits(1), + Effect.ensuring(release), + Effect.forkIn(serviceScope), + ); + // Dispatch owns the accepted delivery now; retries must keep its claim. + claimAcquired = false; + return { _tag: "accepted" as const, deliveryId, outcome: "accepted" as const }; + }).pipe( + Effect.onError(() => + Effect.all([ + releaseClaim(undefined), + (request.relayDeliveryId === undefined || claimAcquired + ? releaseReplay(deliveryId) + : Effect.void + ).pipe(Effect.ignore), + ]), + ), + ), + ); + }); + + const dispatchPending = Effect.fn("ScheduledTaskService.dispatchPendingWebhooks")(function* () { + const now = DateTime.toEpochMillis(yield* localNow); + yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'dispatching' WHERE state = 'pending' AND due_at <= ${now}`; + const tasks = yield* sql<{ task_id: ScheduledTaskId }>` + SELECT DISTINCT task_id FROM scheduled_task_webhook_pending WHERE state IN ('dispatching', 'running', 'completed', 'failed') + `; + yield* Effect.forEach( + tasks, + ({ task_id: taskId }) => + Effect.gen(function* () { + const reserved = yield* Ref.modify(pendingInFlight, (active) => + active.has(taskId) + ? ([false, active] as const) + : ([true, new Set(active).add(taskId)] as const), + ); + if (!reserved) return; + const permit = yield* webhookPermit(taskId); + // One worker per task waits on its permit, then drains persisted rows in order. + // Other tasks remain independent and ticks never reload an active task's payloads. + const dispatch = Effect.gen(function* () { + while (true) { + const [entry] = yield* sql<{ + delivery_id: string; + task_json: string; + request_json: string; + state: string; + }>` + SELECT delivery_id, task_json, request_json, state FROM scheduled_task_webhook_pending + WHERE task_id = ${taskId} AND state IN ('dispatching', 'running', 'completed', 'failed') ORDER BY due_at, rowid LIMIT 1 + `; + if (entry === undefined) return; + const remove = sql`DELETE FROM scheduled_task_webhook_pending WHERE delivery_id = ${entry.delivery_id}`; + if (entry.state === "completed" || entry.state === "failed") { + if (entry.state === "failed") { + yield* markDeliveryFailed(entry.delivery_id, "The run failed to start."); + yield* releaseFailedDelivery(entry.delivery_id); + } + yield* remove; + continue; + } + const prepared = yield* Effect.gen(function* () { + const task = yield* decodePendingTask(entry.task_json); + const request = yield* decodePendingRequest(entry.request_json); + const frameId = Buffer.from(yield* crypto.randomBytes(6)).toString("hex"); + return { task, rendered: renderWebhookPrompt(task.prompt, request, frameId) }; + }).pipe(Effect.exit); + if (Exit.isFailure(prepared)) { + if (Cause.hasInterruptsOnly(prepared.cause)) + return yield* Effect.failCause(prepared.cause); + yield* markDeliveryFailed( + entry.delivery_id, + "The pending webhook payload could not be prepared.", + ); + yield* releaseFailedDelivery(entry.delivery_id); + yield* remove; + continue; + } + const { task, rendered } = prepared.value; + if (rendered.prompt.trim().length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { + yield* markDeliveryFailed(entry.delivery_id, PROMPT_TOO_LONG); + yield* releaseFailedDelivery(entry.delivery_id); + } else { + yield* sql`UPDATE scheduled_task_webhook_deliveries SET outcome = 'accepted', error = NULL, rendered_prompt = ${rendered.prompt.slice(0, WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT)}, missing_fields_json = ${encodeMissingFieldsJson(rendered.missing)} WHERE delivery_id = ${entry.delivery_id}`; + if (!(yield* runWebhookDelivery(task, entry.delivery_id, rendered.prompt))) + return; + } + yield* remove; + } + }); + yield* dispatch.pipe( + permit.withPermits(1), + Effect.catch((cause) => + Effect.logWarning("Pending webhook dispatch failed", { cause }), ), - Effect.catchTags({ - WebhookDeliverySkipped: (skipped) => - runOutcome("skipped").pipe( - Effect.andThen(markDeliveryFailed(deliveryId, skipped.reason)), - ), - }), - // The log is readable over RPC, so it gets a fixed reason; the - // cause, which can carry request data, stays in the server log. - Effect.catchCause((cause) => - Effect.logWarning("Webhook dispatch failed", { taskId: task.id, cause }).pipe( - Effect.andThen(runOutcome("failed")), - Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), - ), + Effect.ensuring( + Ref.update(pendingInFlight, (active) => { + const next = new Set(active); + next.delete(taskId); + return next; + }), ), - permit.withPermits(1), - Effect.withSpan("ScheduledTaskService.runWebhookDelivery", { - root: true, - attributes: { - "scheduled_task.id": task.id, - "scheduled_task.webhook.delivery_id": deliveryId, - }, - }), - Effect.ensuring(release), + Effect.withSpan("ScheduledTaskService.dispatchPendingWebhook", { root: true }), Effect.forkIn(serviceScope), ); - return { _tag: "accepted" as const, deliveryId, outcome: "accepted" as const }; }), - ); - }); + { discard: true }, + ); + }); + yield* scheduler.register("webhook-coalescing", dispatchPending()); return ScheduledTaskService.of({ list, @@ -1592,6 +2156,7 @@ export const layer = Layer.effect( upsert, setEnabled, delete: deleteTask, + deleteForThread, runNow, rotateWebhookToken, listWebhookDeliveries, diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index 007329df40d9..65cecbcf4328 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -3,27 +3,119 @@ import * as NodeCrypto from "node:crypto"; import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto"; import { assert, it } from "@effect/vitest"; -import { ScheduledTaskUpsertInput, SecretRequestError } from "@t3tools/contracts"; +import { + ScheduledTaskUpsertInput, + ScheduledTaskWebhookDeliveryId, + SecretRequestError, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Metric from "effect/Metric"; import * as Queue from "effect/Queue"; import * as EffectScheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as ScheduledTaskService from "./ScheduledTaskService.ts"; +import { renderWebhookPrompt } from "./webhookTemplate.ts"; const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput); +it.effect( + "preserves omitted dedupe settings, releases duplicate claims, and retains coalesced claims", + () => + withService(({ service, sql, launches }) => + Effect.gen(function* () { + const dedupe = { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.group}}", + coalesceWindowSeconds: 10, + }; + const { task } = yield* service.upsert( + yield* webhookTaskInput({ schedule: { type: "webhook", dedupe } }), + ); + const updated = yield* service.upsert(yield* webhookTaskInput()); + assert.deepEqual( + updated.task.schedule.type === "webhook" && updated.task.schedule.dedupe, + dedupe, + ); + const request = (id: string, bodyText = "{}", relayDeliveryId?: string) => + requestFor(task, { + headers: { "delivery-id": id }, + bodyText, + body: new TextEncoder().encode(bodyText), + ...(relayDeliveryId === undefined ? {} : { relayDeliveryId }), + }); + yield* service.triggerWebhook(request("A")); + yield* Queue.take(launches); + yield* service.triggerWebhook(request("B1", '{"group":"B"}')); + for (let i = 0; i < 5; i++) { + const duplicate = yield* service.triggerWebhook(request("A", "{}", `duplicate-${i}`)); + assert.equal(duplicate._tag === "accepted" && duplicate.outcome, "duplicate"); + const coalesced = yield* service.triggerWebhook( + request(`B${i + 2}`, '{"group":"B"}', `coalesced-${i}`), + ); + assert.equal(coalesced._tag === "accepted" && coalesced.outcome, "coalesced"); + } + const claims = yield* sql<{ + relay_delivery_id: string; + }>`SELECT relay_delivery_id FROM scheduled_task_webhook_relay_deliveries ORDER BY relay_delivery_id`; + assert.deepEqual( + claims.map((claim) => claim.relay_delivery_id), + Array.from({ length: 5 }, (_, i) => `coalesced-${i}`), + ); + assert.isTrue( + (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.every( + (delivery) => delivery.outcome !== "duplicate" && delivery.outcome !== "coalesced", + ), + ); + const fresh = yield* service.triggerWebhook(request("C", "{}", "duplicate-0")); + assert.equal(fresh._tag === "accepted" && fresh.outcome, "accepted"); + yield* Queue.take(launches); + const cleared = yield* service.upsert( + yield* webhookTaskInput({ schedule: { type: "webhook", dedupe: {} } }), + ); + assert.deepEqual( + cleared.task.schedule.type === "webhook" && cleared.task.schedule.dedupe, + {}, + ); + }), + ), +); + +const waitForSpan = (work: Effect.Effect, name: string) => + Effect.gen(function* () { + const done = yield* Deferred.make(); + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (...args) => { + end(...args); + if (span.name === name) Deferred.doneUnsafe(done, Effect.void); + }; + return span; + }, + }); + const result = yield* work.pipe(Effect.withTracer(tracer)); + yield* Deferred.await(done); + return result; + }); + +const drainPendingDispatch = (workers: Map>) => + waitForSpan(workers.get("webhook-coalescing")!, "ScheduledTaskService.dispatchPendingWebhook"); + type LaunchInput = ThreadLaunchService.ThreadLaunchInput; const webhookTaskInput = (overrides: Record = {}) => @@ -64,22 +156,83 @@ const withService = ( body: (input: { readonly service: ScheduledTaskService.ScheduledTaskService["Service"]; readonly launches: Queue.Queue; + readonly sql: SqlClient.SqlClient; readonly secretsByRef: Map; }) => Effect.Effect, - options: { readonly gate?: Deferred.Deferred; readonly relayHookBaseUrl?: string } = {}, + options: { + readonly onFrame?: () => void; + readonly frameGate?: { + readonly started: Deferred.Deferred; + readonly release: Deferred.Deferred; + }; + readonly completedLaunches?: Set; + readonly failLaunches?: { remaining: number }; + readonly gate?: Deferred.Deferred; + readonly gateForTask?: (input: LaunchInput) => Deferred.Deferred | undefined; + readonly relayHookBaseUrl?: string; + readonly sql?: SqlClient.SqlClient; + readonly workers?: Map>; + } = {}, ) => Effect.gen(function* () { const launches = yield* Queue.unbounded(); const secretsByRef = new Map(); const layerDependencies = Layer.mergeAll( - NodePlatformCrypto.layer, - Scheduler.layer, + options.frameGate === undefined && options.onFrame === undefined + ? NodePlatformCrypto.layer + : Layer.effect( + Crypto.Crypto, + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + return { + ...crypto, + randomBytes: (size: number) => + Effect.suspend(() => { + if (size === 6) options.onFrame?.(); + return size === 6 && options.frameGate !== undefined + ? Deferred.succeed(options.frameGate.started, undefined).pipe( + Effect.andThen(Deferred.await(options.frameGate.release)), + Effect.andThen(crypto.randomBytes(size)), + ) + : crypto.randomBytes(size); + }), + }; + }), + ).pipe(Layer.provide(NodePlatformCrypto.layer)), + options.workers === undefined + ? Scheduler.layer + : Layer.succeed(Scheduler.Scheduler, { + register: (name: string, work: Effect.Effect) => + Effect.gen(function* () { + const context = yield* Effect.context(); + options.workers!.set(name, work.pipe(Effect.provideContext(context), Effect.orDie)); + }), + }), Layer.mock(ThreadLaunchService.ThreadLaunchService)({ launch: (input) => - Queue.offer(launches, input).pipe( - Effect.andThen(options.gate ? Deferred.await(options.gate) : Effect.void), - Effect.as({ threadId: "thread-1", resumed: false } as never), - ), + Effect.suspend(() => { + if (options.completedLaunches?.has(input.commandId)) + return Effect.succeed({ threadId: "thread-1", resumed: false } as never); + options.completedLaunches?.add(input.commandId); + return Queue.offer(launches, input).pipe( + Effect.andThen( + (options.gateForTask?.(input) ?? options.gate) + ? Deferred.await((options.gateForTask?.(input) ?? options.gate)!) + : Effect.void, + ), + Effect.andThen( + Effect.sync(() => { + if (options.failLaunches && options.failLaunches.remaining-- > 0) + throw new Error("injected start failure"); + }), + ), + Effect.as({ threadId: "thread-1", resumed: false } as never), + ); + }), + }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), }), Layer.mock(ThreadManagementService.ThreadManagementService)({}), Layer.mock(SecretRequests.SecretRequests)({ @@ -98,9 +251,20 @@ const withService = ( ); return yield* Effect.gen(function* () { const service = yield* ScheduledTaskService.ScheduledTaskService; - return yield* body({ service, launches, secretsByRef }); - }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); - }).pipe(Effect.provide(SqlitePersistence.layerMemory)); + const sql = yield* SqlClient.SqlClient; + return yield* body({ service, launches, sql, secretsByRef }); + }).pipe( + Effect.provide( + Layer.fresh(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies))), + ), + ); + }).pipe( + Effect.provide( + options.sql === undefined + ? SqlitePersistence.layerMemory + : Layer.succeed(SqlClient.SqlClient, options.sql), + ), + ); it.effect("dispatches exactly the rendered prompt and logs the delivery", () => withService(({ service, launches }) => @@ -123,7 +287,10 @@ it.effect("dispatches exactly the rendered prompt and logs the delivery", () => `scheduled-task:${task.id}:webhook:${result._tag === "accepted" ? result.deliveryId : ""}`, ); - const { deliveries } = yield* service.listWebhookDeliveries({ id: task.id }); + const { deliveries } = yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + }); assert.equal(deliveries.length, 1); assert.equal(deliveries[0]?.outcome, "accepted"); const { delivery } = yield* service.getWebhookDelivery({ @@ -179,7 +346,11 @@ it.effect("answers not found for a wrong token or unknown hook without logging", assert.equal(wrongToken._tag, "not_found"); const unknown = yield* service.triggerWebhook(requestFor(task, { hookId: "missing" })); assert.equal(unknown._tag, "not_found"); - assert.equal((yield* service.listWebhookDeliveries({ id: task.id })).deliveries.length, 0); + assert.equal( + (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })).deliveries + .length, + 0, + ); }), ), ); @@ -244,9 +415,10 @@ it.effect("checks the configured signature and keeps the secret write-only", () ); assert.isTrue(resaved.task.webhook?.hasSecret); - const outcomes = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.map( - (delivery) => [delivery.outcome, delivery.signatureVerified], - ); + const outcomes = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((delivery) => [delivery.outcome, delivery.signatureVerified]); assert.deepEqual(outcomes.toSorted(), [ ["accepted", true], ["rejected_signature", false], @@ -267,48 +439,6 @@ it.effect("logs but does not run deliveries to a disabled task, and refuses run ), ); -it.effect("queues a burst of deliveries instead of dropping them", () => - Effect.gen(function* () { - const gate = yield* Deferred.make(); - yield* withService( - ({ service, launches }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput()); - const results = yield* Effect.forEach([1, 2, 3], () => - service.triggerWebhook(requestFor(task)), - ); - assert.deepEqual( - results.map((result) => result._tag), - ["accepted", "accepted", "accepted"], - ); - yield* Queue.take(launches); - yield* Deferred.succeed(gate, undefined); - const rest = yield* Effect.all([Queue.take(launches), Queue.take(launches)]); - assert.equal(new Set(rest.map((launch) => launch.commandId)).size, 2); - }), - { gate }, - ); - }), -); - -it.effect("rate limits a hook past 60 deliveries a minute", () => - withService(({ service }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); - const results = yield* Effect.forEach(Array.from({ length: 61 }), () => - service.triggerWebhook(requestFor(task)), - ); - assert.equal(results.at(-2)?._tag, "disabled"); - assert.equal(results.at(-1)?._tag, "rate_limited"); - yield* Effect.forEach([1, 2, 3], () => service.triggerWebhook(requestFor(task))); - const outcomes = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.map( - (delivery) => delivery.outcome, - ); - assert.equal(outcomes.filter((outcome) => outcome === "rate_limited").length, 1); - }), - ), -); - it.effect("a save carrying a stale token cannot undo a rotation", () => withService(({ service }) => Effect.gen(function* () { @@ -321,29 +451,6 @@ it.effect("a save carrying a stale token cannot undo a rotation", () => ), ); -it.effect("keeps the newest 50 deliveries when they share a timestamp", () => - withService(({ service }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); - const ids = yield* Effect.forEach(Array.from({ length: 55 }), (_, index) => - service.triggerWebhook(requestFor(task, { query: `n=${index}` })).pipe(Effect.as(index)), - ); - const { deliveries } = yield* service.listWebhookDeliveries({ id: task.id }); - assert.equal(deliveries.length, 50); - const first = yield* service.getWebhookDelivery({ - id: task.id, - deliveryId: deliveries[0]!.id, - }); - const last = yield* service.getWebhookDelivery({ - id: task.id, - deliveryId: deliveries.at(-1)!.id, - }); - assert.equal(first.delivery.query, `n=${ids.at(-1)}`); - assert.equal(last.delivery.query, "n=5"); - }), - ), -); - it.effect("keeps credential headers and query values out of the delivery log", () => withService(({ service }) => Effect.gen(function* () { @@ -359,7 +466,10 @@ it.effect("keeps credential headers and query values out of the delivery log", ( }, }), ); - const [summary] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + const [summary] = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; const { delivery } = yield* service.getWebhookDelivery({ id: task.id, deliveryId: summary!.id, @@ -456,87 +566,6 @@ it.effect("a save without a secret keeps a secret changed after it was read", () ), ); -it.effect("caps deliveries waiting behind a stuck run", () => - Effect.gen(function* () { - const gate = yield* Deferred.make(); - yield* withService( - ({ service, launches }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput()); - yield* service.triggerWebhook(requestFor(task)); - yield* Queue.take(launches); - const waiting = yield* Effect.forEach(Array.from({ length: 20 }), () => - service.triggerWebhook(requestFor(task)), - ); - assert.equal(waiting.filter((result) => result._tag === "accepted").length, 19); - assert.equal(waiting.at(-1)?._tag, "rate_limited"); - const logged = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; - assert.equal(logged.length, 20); - yield* Deferred.succeed(gate, undefined); - }), - { gate }, - ); - }), -); - -it.effect("logs a body's first 64 KiB by bytes, not characters", () => - withService(({ service }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); - const text = "界".repeat(30_000); - const body = new TextEncoder().encode(text); - yield* service.triggerWebhook(requestFor(task, { body, bodyText: text })); - const [summary] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; - const { delivery } = yield* service.getWebhookDelivery({ - id: task.id, - deliveryId: summary!.id, - }); - assert.isTrue(delivery.bodyTruncated); - assert.isAtMost(new TextEncoder().encode(delivery.body).byteLength, 64 * 1024 + 3); - }), - ), -); - -it.effect("counts the prompt as the provider does, without surrounding whitespace", () => - withService(({ service, launches }) => - Effect.gen(function* () { - // Values are framed, so only an empty placeholder can leave padding at the edge. - const { task } = yield* service.upsert( - yield* webhookTaskInput({ prompt: `{{body.none}}${" ".repeat(2_000)}{{body.text}}` }), - ); - const text = JSON.stringify({ text: "x".repeat(119_000) }); - const result = yield* service.triggerWebhook( - requestFor(task, { body: new TextEncoder().encode(text), bodyText: text }), - ); - assert.equal(result._tag, "accepted"); - yield* Queue.take(launches); - }), - ), -); - -it.effect("does not start a run when the filled-in prompt is too long", () => - withService(({ service, launches }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ prompt: "{{body}}" })); - const text = "x".repeat(200_000); - const body = new TextEncoder().encode(text); - const result = yield* service.triggerWebhook(requestFor(task, { body, bodyText: text })); - assert.equal(result._tag, "accepted"); - assert.equal(yield* Queue.size(launches), 0); - const { delivery } = yield* service.getWebhookDelivery({ - id: task.id, - deliveryId: result._tag === "accepted" ? result.deliveryId : ("" as never), - }); - assert.equal(delivery.outcome, "dispatch_failed"); - assert.equal(delivery.error, "The filled-in prompt is too long."); - assert.equal(delivery.renderedPrompt?.length, 64 * 1024); - const ok = yield* service.triggerWebhook(requestFor(task)); - assert.equal(ok._tag, "accepted"); - yield* Queue.take(launches); - }), - ), -); - it.effect("a held request already delivered directly runs only once", () => withService(({ service, launches }) => Effect.gen(function* () { @@ -550,12 +579,65 @@ it.effect("a held request already delivered directly runs only once", () => assert.equal(direct._tag, "accepted"); assert.deepEqual(replayed, { ...direct, outcome: "duplicate" } as typeof replayed); yield* Queue.take(launches); - const logged = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + const logged = (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })) + .deliveries; assert.equal(logged.length, 1); }), ), ); +it.effect("relay retries recover failed acceptance without releasing another request's claim", () => + withService(({ service, launches, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* webhookTaskInput()); + const request = requestFor(task, { relayDeliveryId: "retry-after-log-failure" }); + yield* sql` + CREATE TRIGGER fail_webhook_delivery_log + BEFORE INSERT ON scheduled_task_webhook_deliveries + BEGIN + SELECT RAISE(FAIL, 'injected delivery log failure'); + END + `; + const error = yield* service.triggerWebhook(request).pipe(Effect.flip); + assert.equal(error.message, "Could not record webhook delivery."); + assert.equal(yield* Queue.size(launches), 0); + assert.isEmpty( + (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })).deliveries, + ); + yield* sql`DROP TRIGGER fail_webhook_delivery_log`; + + const retried = yield* Effect.all( + [service.triggerWebhook(request), service.triggerWebhook(request)], + { concurrency: "unbounded" }, + ); + assert.deepEqual( + retried.map((result) => (result._tag === "accepted" ? result.outcome : result._tag)).sort(), + ["accepted", "duplicate"], + ); + assert.include((yield* Queue.take(launches)).commandId, request.relayDeliveryId!); + + yield* sql` + CREATE TRIGGER fail_webhook_claim + BEFORE INSERT ON scheduled_task_webhook_relay_deliveries + BEGIN + SELECT RAISE(FAIL, 'injected claim failure'); + END + `; + const retryError = yield* service.triggerWebhook(request).pipe(Effect.flip); + assert.equal(retryError.message, "Could not record webhook delivery."); + yield* sql`DROP TRIGGER fail_webhook_claim`; + const replayed = yield* service.triggerWebhook(request); + assert.equal(replayed._tag === "accepted" ? replayed.outcome : replayed._tag, "duplicate"); + assert.equal(yield* Queue.size(launches), 0); + assert.equal( + (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })).deliveries + .length, + 1, + ); + }), + ), +); + it.effect("a held request whose sender hung up mid-request still runs", () => withService(({ service, launches }) => Effect.gen(function* () { @@ -599,57 +681,6 @@ it.effect("a held request runs once even after the log has trimmed it", () => ), ); -it.effect("a rate-limited held request can run on a later pass", () => - withService(({ service, launches }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); - yield* Effect.forEach(Array.from({ length: 60 }), () => - service.triggerWebhook(requestFor(task)), - ); - const limited = yield* service.triggerWebhook(requestFor(task, { relayDeliveryId: "later" })); - assert.equal(limited._tag, "rate_limited"); - yield* service.upsert(yield* webhookTaskInput()); - yield* TestClock.adjust("61 seconds"); - const retried = yield* service.triggerWebhook(requestFor(task, { relayDeliveryId: "later" })); - assert.equal(retried._tag, "accepted"); - yield* Queue.take(launches); - }), - ), -); - -it.effect("logs a held request at the time the relay received it", () => - withService(({ service }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); - const receivedAt = DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { minutes: 5 })); - yield* service.triggerWebhook(requestFor(task, { relayDeliveryId: "relay-2", receivedAt })); - const [delivery] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; - assert.equal(delivery?.receivedAt, receivedAt); - }), - ), -); - -it.effect("a receive time in the future counts as now", () => - withService(({ service, launches }) => - Effect.gen(function* () { - const { task } = yield* service.upsert( - yield* webhookTaskInput({ schedule: { type: "webhook", maxDeliveryAgeMinutes: 30 } }), - ); - const now = yield* DateTime.now; - const result = yield* service.triggerWebhook( - requestFor(task, { - relayDeliveryId: "future", - receivedAt: DateTime.formatIso(DateTime.add(now, { days: 365 })), - }), - ); - assert.equal(result._tag, "accepted"); - yield* Queue.take(launches); - const [delivery] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; - assert.equal(delivery?.receivedAt, DateTime.formatIso(now)); - }), - ), -); - it.effect("skips a held request older than the task's max age", () => withService(({ service, launches }) => Effect.gen(function* () { @@ -668,41 +699,15 @@ it.effect("skips a held request older than the task's max age", () => requestFor(task, { relayDeliveryId: "fresh", receivedAt: fresh }), ); assert.equal(ok._tag, "accepted"); - const outcomes = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.map( - (delivery) => delivery.outcome, - ); + const outcomes = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((delivery) => delivery.outcome); assert.includeMembers(outcomes, ["expired", "accepted"]); }), ), ); -it.effect("runs a held request of any age when no max age is set", () => - withService(({ service }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput()); - const now = yield* DateTime.now; - const result = yield* service.triggerWebhook( - requestFor(task, { - relayDeliveryId: "ancient", - receivedAt: DateTime.formatIso(DateTime.subtract(now, { hours: 23 })), - }), - ); - assert.equal(result._tag, "accepted"); - }), - ), -); - -it.effect("deleting a task removes its delivery log", () => - withService(({ service }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); - yield* service.triggerWebhook(requestFor(task)); - yield* service.delete({ id: task.id }); - assert.equal((yield* service.listWebhookDeliveries({ id: task.id })).deliveries.length, 0); - }), - ), -); - const githubSignature = (secret: string) => `sha256=${NodeCrypto.createHmac("sha256", secret).update(pullRequestBody).digest("hex")}`; @@ -811,23 +816,868 @@ it.effect("a signature without any secret is refused", () => ), ); -const signatureFor = (secret: string) => - `sha256=${NodeCrypto.createHmac("sha256", secret).update(pullRequestBody).digest("hex")}`; +it.effect("dedupes replay keys per task, persists them, and expires them after seven days", () => + withService( + ({ service, launches, sql }) => + Effect.gen(function* () { + const schedule = { type: "webhook", dedupe: { replayKey: "{{headers.delivery-id}}" } }; + const { task } = yield* service.upsert(yield* webhookTaskInput({ schedule })); + const request = requestFor(task, { headers: { "delivery-id": "A" } }); + const first = yield* service.triggerWebhook(request); + assert.equal(first._tag === "accepted" && first.outcome, "accepted"); + yield* Queue.take(launches); + const replay = yield* service.triggerWebhook(request); + assert.equal(replay._tag === "accepted" && replay.outcome, "duplicate"); + assert.equal(yield* Queue.size(launches), 0); + const other = yield* service.upsert( + yield* webhookTaskInput({ id: "scheduled-task:other", schedule }), + ); + yield* service.triggerWebhook(requestFor(other.task, { headers: request.headers })); + yield* Queue.take(launches); + yield* withService( + ({ service: restarted }) => + Effect.gen(function* () { + const replay = yield* restarted.triggerWebhook(request); + assert.equal(replay._tag === "accepted" && replay.outcome, "duplicate"); + }), + { sql, workers: new Map() }, + ); + yield* TestClock.adjust("7 days"); + const expired = yield* service.triggerWebhook(request); + assert.equal(expired._tag === "accepted" && expired.outcome, "accepted"); + yield* Queue.take(launches); + assert.include( + (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((d) => d.outcome), + "duplicate", + ); + }), + { workers: new Map() }, + ), +); -const deliveriesCounted = (outcome: string, source: "relay" | "direct") => - Metric.snapshot.pipe( - Effect.map((snapshots) => { - const found = snapshots.find( - (snapshot) => - snapshot.id === "t3_webhook_deliveries_total" && - snapshot.attributes?.outcome === outcome && - snapshot.attributes?.source === source, +it.effect.each([1, 3])( + "coalesces %i deliveries into one run with the latest body, headers and query", + (count) => { + const workers = new Map>(); + return withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}} {{headers.version}} {{query.version}}", + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body.key}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + for (let value = 1; value <= count; value++) { + const bodyText = JSON.stringify({ key: "B", value }); + yield* service.triggerWebhook( + requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { version: String(value) }, + query: `version=${value}`, + }), + ); + if (value < count) yield* TestClock.adjust("2 seconds"); + } + const owner = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.find((delivery) => delivery.outcome === "accepted")!; + assert.isTrue(owner.pendingWindow); + const beforeDispatch = (yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: owner.id, + acceptAnyOutcome: true, + })).delivery; + assert.isTrue(beforeDispatch.pendingWindow); + assert.equal(beforeDispatch.body, JSON.stringify({ key: "B", value: count })); + assert.equal(beforeDispatch.headers.version, String(count)); + assert.equal(beforeDispatch.query, `version=${count}`); + assert.equal( + beforeDispatch.bodyBytes, + new TextEncoder().encode(beforeDispatch.body).byteLength, + ); + assert.equal(yield* Queue.size(launches), 0); + yield* workers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + yield* TestClock.adjust(count === 1 ? "10 seconds" : "6 seconds"); + yield* workers.get("webhook-coalescing")!; + const launched = yield* Queue.take(launches); + assert.equal( + (launched.initialMessage!.text.match(new RegExp(`>${count}<`, "g")) ?? []).length, + 3, + ); + yield* workers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + const outcomes = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((d) => d.outcome); + const afterDispatch = (yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: owner.id, + acceptAnyOutcome: true, + })).delivery; + assert.isFalse(afterDispatch.pendingWindow); + assert.equal(afterDispatch.body, beforeDispatch.body); + assert.include(afterDispatch.renderedPrompt!, `>${count}<`); + assert.equal(outcomes.filter((o) => o === "accepted").length, 1); + assert.equal(outcomes.filter((o) => o === "coalesced").length, count - 1); + }), + { workers }, + ); + }, +); + +it.effect("recovers a persisted coalescing window after restarting the service", () => { + const workers = new Map>(); + return withService( + ({ service, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{headers.content-type}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* TestClock.adjust("4 seconds"); + const restartedWorkers = new Map>(); + yield* withService( + ({ launches }) => + Effect.gen(function* () { + yield* restartedWorkers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + yield* TestClock.adjust("6 seconds"); + yield* restartedWorkers.get("webhook-coalescing")!; + yield* Queue.take(launches); + yield* restartedWorkers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + }), + { sql, workers: restartedWorkers }, + ); + }), + { workers }, + ); +}); + +it.effect("rolls back replay and pending state when recording a delivery fails", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.content-type}}", + coalesceKey: "{{headers.content-type}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + yield* sql`CREATE TRIGGER fail_dedupe_log BEFORE INSERT ON scheduled_task_webhook_deliveries BEGIN SELECT RAISE(FAIL, 'injected'); END`; + yield* service.triggerWebhook(requestFor(task)).pipe(Effect.flip); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 0); + yield* sql`DROP TRIGGER fail_dedupe_log`; + const retry = yield* service.triggerWebhook(requestFor(task)); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* workers.get("webhook-coalescing")!; + yield* Queue.take(launches); + }), + { workers }, + ); +}); + +it.effect("queue-full deliveries release replay keys so a later retry runs", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { type: "webhook", dedupe: { replayKey: "{{headers.delivery-id}}" } }, + }), + ); + const request = (i: number) => + requestFor(task, { headers: { "delivery-id": String(i) } }); + for (let i = 0; i < 20; i++) yield* service.triggerWebhook(request(i)); + yield* Queue.take(launches); + const full = yield* service.triggerWebhook(request(20)); + assert.equal(full._tag === "rate_limited" && full.outcome, "queue_full"); + yield* Deferred.succeed(gate, undefined); + for (let i = 1; i < 20; i++) yield* Queue.take(launches); + const retry = yield* service.triggerWebhook(request(20)); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* Queue.take(launches); + }), + { gate }, + ); + }), +); + +it.effect.each(["task_json", "request_json"] as const)( + "removes corrupt pending %s instead of retrying forever", + (column) => { + const workers = new Map>(); + return withService( + ({ service, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{body}}", + coalesceKey: "{{body}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* sql`UPDATE scheduled_task_webhook_pending SET ${sql(column)} = 'broken'`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal( + (yield* service.listWebhookDeliveries({ id: task.id })).deliveries[0]!.outcome, + "dispatch_failed", + ); + yield* workers.get("webhook-coalescing")!; + }), + { workers }, + ); + }, +); + +it.effect( + "restarting after a coalesced launch before pending deletion does not launch or count it twice", + () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const accepted = yield* service.triggerWebhook(requestFor(task)); + yield* sql`CREATE TRIGGER fail_pending_delete BEFORE DELETE ON scheduled_task_webhook_pending BEGIN SELECT RAISE(FAIL,'crash after launch'); END`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + const launch = yield* Queue.take(launches); + assert.equal( + launch.commandId, + `scheduled-task:${task.id}:webhook:${accepted._tag === "accepted" && accepted.deliveryId}`, + ); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal( + (yield* sql`SELECT * FROM scheduled_task_webhook_pending WHERE state = 'completed'`) + .length, + 1, + ); + yield* sql`DROP TRIGGER fail_pending_delete`; + const restartedWorkers = new Map>(); + yield* withService( + ({ service: restarted, launches: secondLaunches }) => + Effect.gen(function* () { + yield* drainPendingDispatch(restartedWorkers); + assert.equal(yield* Queue.size(secondLaunches), 0); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 1); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + }), + { sql, workers: restartedWorkers }, + ); + }), + { workers }, + ); + }, +); + +it.effect("guards bad-signature floods separately from valid run admission", () => + withService(({ service, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + signature: { header: "signature", encoding: "hex", prefix: "", secret: "secret" }, + }, + }), + ); + const results = yield* Effect.forEach(Array.from({ length: 75 }), () => + service.triggerWebhook(requestFor(task)), + ); + assert.equal(results.filter((result) => result._tag === "rejected_signature").length, 60); + assert.equal(results.filter((result) => result._tag === "rate_limited").length, 15); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_deliveries`).length, 51); + }), + ), +); + +it.effect("start failures release both replay keys and relay claims for redelivery", () => + withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { type: "webhook", dedupe: { replayKey: "{{body}}" } }, + }), + ); + const request = requestFor(task, { relayDeliveryId: "failed-start" }); + yield* waitForSpan( + service.triggerWebhook(request), + "ScheduledTaskService.runWebhookDelivery", + ); + yield* Queue.take(launches); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_relay_deliveries`).length, 0); + const retry = yield* waitForSpan( + service.triggerWebhook(request), + "ScheduledTaskService.runWebhookDelivery", + ); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* Queue.take(launches); + assert.equal((yield* service.list()).tasks[0]!.lastRunStatus, "succeeded"); + }), + { failLaunches: { remaining: 1 } }, + ), +); + +it.effect("main retention keeps fifty runs and preserves filtered and dedupe buckets", () => + withService(({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* webhookTaskInput()); + yield* service.triggerWebhook(requestFor(task)); + yield* Queue.take(launches); + for (const outcome of ["filtered", "duplicate"]) { + yield* sql`WITH RECURSIVE n(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < 20) + INSERT INTO scheduled_task_webhook_deliveries + SELECT ${outcome} || n.value, d.task_id, d.received_at, d.method, d.query, d.headers_json, d.body, + d.body_bytes, d.body_truncated, ${outcome}, d.signature_verified, d.missing_fields_json, d.rendered_prompt, d.error + FROM n CROSS JOIN (SELECT * FROM scheduled_task_webhook_deliveries WHERE outcome = 'accepted' LIMIT 1) d`; + } + for (let i = 0; i < 59; i++) { + assert.equal((yield* service.triggerWebhook(requestFor(task)))._tag, "accepted"); + yield* Queue.take(launches); + } + const counts = yield* sql<{ + outcome: string; + count: number; + }>`SELECT outcome, COUNT(*) AS count FROM scheduled_task_webhook_deliveries GROUP BY outcome ORDER BY outcome`; + assert.deepEqual(counts, [ + { outcome: "accepted", count: 50 }, + { outcome: "duplicate", count: 20 }, + { outcome: "filtered", count: 20 }, + ]); + }), + ), +); + +it.effect( + "a valid request refunds its own reservation without expiring an interleaved rejection", + () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + signature: { header: "signature", encoding: "hex", prefix: "", secret: "secret" }, + }, + }), + ); + const signature = NodeCrypto.createHmac("sha256", "secret") + .update(pullRequestBody) + .digest("hex"); + const valid = yield* service + .triggerWebhook(requestFor(task, { headers: { signature } })) + .pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* TestClock.adjust("30 seconds"); + assert.equal( + (yield* service.triggerWebhook(requestFor(task)))._tag, + "rejected_signature", + ); + yield* Deferred.succeed(release, undefined); + assert.equal((yield* Fiber.join(valid))._tag, "accepted"); + yield* Queue.take(launches); + // Only the valid reservation would have expired now; the rejection must still count. + yield* TestClock.adjust("31 seconds"); + for (let i = 0; i < 59; i++) + assert.equal( + (yield* service.triggerWebhook(requestFor(task)))._tag, + "rejected_signature", + ); + assert.equal((yield* service.triggerWebhook(requestFor(task)))._tag, "rate_limited"); + }), + { frameGate: { started, release } }, + ); + }), +); + +it.effect("keeps replay keys when dispatch succeeds but completion bookkeeping fails", () => + withService(({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { replayKey: "{{body}}" }, + }, + }), + ); + yield* sql`CREATE TRIGGER fail_completion BEFORE UPDATE ON scheduled_tasks + WHEN NEW.last_run_status = 'succeeded' BEGIN SELECT RAISE(FAIL, 'completion unavailable'); END`; + yield* waitForSpan( + service.triggerWebhook(requestFor(task)), + "ScheduledTaskService.runWebhookDelivery", ); - return found?.type === "Counter" ? Number(found.state.count) : 0; + yield* Queue.take(launches); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 1); + yield* sql`DROP TRIGGER fail_completion`; + const retry = yield* service.triggerWebhook(requestFor(task)); + assert.equal(retry._tag === "accepted" && retry.outcome, "duplicate"); + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); }), + ), +); + +it.effect.each([119_000, 120_000])( + "bounds coalesced prompt logs and checks trimmed length for %i body characters", + (length) => { + const workers = new Map>(); + return withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: `{{body.absent}}${" ".repeat(10_000)}{{body.text}}`, + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body.group}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const bodyText = JSON.stringify({ group: "B", text: "x".repeat(length) }); + const accepted = yield* service.triggerWebhook( + requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }), + ); + assert.equal(accepted._tag, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + const delivery = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries[0]!; + if (length === 119_000) { + const launch = yield* Queue.take(launches); + assert.isAbove(launch.initialMessage!.text.length, 120_000); + assert.equal(delivery.outcome, "accepted"); + const detail = yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: delivery.id, + }); + assert.equal(detail.delivery.renderedPrompt!.length, 64 * 1024); + } else { + assert.equal(yield* Queue.size(launches), 0); + assert.equal(delivery.outcome, "dispatch_failed"); + assert.equal(delivery.error, "The filled-in prompt is too long."); + } + }), + { workers }, + ); + }, +); + +it.effect.each([1, 11])( + "keeps relay claims for %i coalesced updates after their window closes", + (count) => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body.group}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const request = (value: number) => { + const bodyText = JSON.stringify({ group: "B", value }); + return requestFor(task, { + relayDeliveryId: `coalesced-relay-${value}`, + bodyText, + body: new TextEncoder().encode(bodyText), + }); + }; + yield* service.triggerWebhook(request(0)); + for (let i = 1; i <= count; i++) { + const result = yield* service.triggerWebhook(request(i)); + assert.equal(result._tag === "accepted" && result.outcome, "coalesced"); + } + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, + count + 1, + ); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, `>${count}<`); + const retry = yield* service.triggerWebhook(request(count)); + assert.equal(retry._tag === "accepted" && retry.outcome, "duplicate"); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal(yield* Queue.size(launches), 0); + }), + { workers }, + ); + }, +); + +it.effect("failed windows release every member's replay key and relay claim", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.group}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string) => { + const bodyText = JSON.stringify({ group: "same", value: id }); + return requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { "delivery-id": id }, + relayDeliveryId: id, + }); + }; + yield* service.triggerWebhook(request("A")); + for (let i = 0; i < 12; i++) yield* service.triggerWebhook(request(`B${i}`)); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, + 13, + ); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + yield* Queue.take(launches); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, 0); + const retry = yield* service.triggerWebhook(request("B11")); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, "B11"); + assert.equal((yield* service.list()).tasks[0]!.runCount, 2); + }), + { workers, failLaunches: { remaining: 1 } }, + ); +}); + +it.effect( + "recovery finishes failed-window claim cleanup without counting the attempt twice", + () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string) => + requestFor(task, { headers: { "delivery-id": id }, relayDeliveryId: id }); + yield* service.triggerWebhook(request("A")); + yield* service.triggerWebhook(request("B")); + yield* sql`CREATE TRIGGER fail_claim_cleanup BEFORE DELETE ON scheduled_task_webhook_relay_deliveries + BEGIN SELECT RAISE(FAIL, 'cleanup unavailable'); END`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + yield* Queue.take(launches); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal( + (yield* sql<{ state: string }>`SELECT state FROM scheduled_task_webhook_pending`)[0]! + .state, + "failed", + ); + yield* sql`DROP TRIGGER fail_claim_cleanup`; + const restartedWorkers = new Map>(); + yield* withService( + ({ service: restarted, launches: restartedLaunches }) => + Effect.gen(function* () { + yield* drainPendingDispatch(restartedWorkers); + assert.equal(yield* Queue.size(restartedLaunches), 0); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 1); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, + 0, + ); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, + 0, + ); + const retry = yield* restarted.triggerWebhook(request("B")); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(restartedWorkers); + yield* Queue.take(restartedLaunches); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 2); + }), + { sql, workers: restartedWorkers }, + ); + }), + { workers, failLaunches: { remaining: 1 } }, + ); + }, +); + +it.effect("dispatches an overdue window exactly once after its deadline passed while stopped", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const task = yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + assert.equal(yield* Queue.size(launches), 0); + return task; + }), + { sql, workers: new Map() }, + ); + // The first service scope is closed before the deadline advances. + yield* TestClock.adjust("30 seconds"); + const restartedWorkers = new Map>(); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + yield* drainPendingDispatch(restartedWorkers); + yield* Queue.take(launches); + for (let i = 0; i < 3; i++) yield* restartedWorkers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + }), + { sql, workers: restartedWorkers }, + ); + const secondRestartWorkers = new Map>(); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + yield* secondRestartWorkers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + assert.equal( + (yield* service.list()).tasks.find((entry) => entry.id === task.id)!.runCount, + 1, + ); + }), + { sql, workers: secondRestartWorkers }, + ); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + +it.effect("removes a window with a render defect and dispatches the task's next window", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.deep}}", + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{headers.group}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string, bodyText: string) => + requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { "delivery-id": id, group: id }, + relayDeliveryId: id, + }); + const bad = request( + "bad-render", + '{"deep":' + "[".repeat(20000) + "0" + "]".repeat(20000) + "}", + ); + assert.throws(() => renderWebhookPrompt(task.prompt, bad, "frame"), RangeError); + const accepted = yield* service.triggerWebhook(bad); + assert.equal(accepted._tag, "accepted"); + yield* service.triggerWebhook(request("good-render", '{"deep":"next window"}')); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, "next window"); + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys WHERE delivery_id = 'delivery:relay:bad-render'`) + .length, + 0, + ); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries WHERE relay_delivery_id = 'bad-render'`) + .length, + 0, + ); + const failed = (yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: ScheduledTaskWebhookDeliveryId.make("delivery:relay:bad-render"), + acceptAnyOutcome: true, + })).delivery; + assert.equal(failed.outcome, "dispatch_failed"); + assert.equal(failed.error, "The pending webhook payload could not be prepared."); + yield* workers.get("webhook-coalescing")!; + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + }), + { workers }, ); +}); + +it.effect.each([false, true])( + "counts an interrupted immediate run beside an unrelated running window (corrupt task: %s)", + (corrupt) => { + const workers = new Map>(); + const completedLaunches = new Set(); + return Effect.gen(function* () { + const gate = yield* Deferred.make(); + yield* withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{headers.group}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const window = yield* service.triggerWebhook( + requestFor(task, { headers: { group: "old" }, bodyText: '{"value":"old"}' }), + ); + yield* sql`CREATE TRIGGER fail_old_completion BEFORE UPDATE ON scheduled_tasks + WHEN NEW.last_run_status = 'succeeded' BEGIN SELECT RAISE(FAIL, 'completion unavailable'); END`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + yield* Queue.take(launches); + yield* sql`DROP TRIGGER fail_old_completion`; + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE state = 'running'`) + .length, + 1, + ); + const immediate = yield* service.triggerWebhook( + requestFor(task, { bodyText: '{"value":"immediate"}' }), + ); + yield* Queue.take(launches); + const [before] = yield* sql<{ + last_run_webhook_delivery_id: string; + model_selection_json: string; + run_count: number; + }>`SELECT last_run_webhook_delivery_id, model_selection_json, run_count FROM scheduled_tasks WHERE task_id = ${task.id}`; + assert.equal( + before!.last_run_webhook_delivery_id, + immediate._tag === "accepted" ? immediate.deliveryId : "", + ); + assert.notEqual( + before!.last_run_webhook_delivery_id, + window._tag === "accepted" ? window.deliveryId : "", + ); + assert.equal(before!.run_count, 0); + if (corrupt) + yield* sql`UPDATE scheduled_tasks SET model_selection_json = 'broken' WHERE task_id = ${task.id}`; + const restartedWorkers = new Map>(); + yield* withService( + ({ service: restarted, launches: recoveredLaunches }) => + Effect.gen(function* () { + const [recovered] = yield* sql<{ + run_count: number; + last_run_status: string; + }>`SELECT run_count, last_run_status FROM scheduled_tasks WHERE task_id = ${task.id}`; + assert.equal(recovered!.run_count, 1); + assert.equal(recovered!.last_run_status, "failed"); + if (corrupt) + yield* sql`UPDATE scheduled_tasks SET model_selection_json = ${before!.model_selection_json} WHERE task_id = ${task.id}`; + yield* drainPendingDispatch(restartedWorkers); + assert.equal(yield* Queue.size(recoveredLaunches), 0); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 2); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, + 0, + ); + yield* restartedWorkers.get("webhook-coalescing")!; + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 2); + }), + { sql, workers: restartedWorkers, completedLaunches }, + ); + }), + { + workers, + completedLaunches, + gateForTask: (input) => + input.initialMessage?.text.includes(">immediate<") ? gate : undefined, + }, + ); + }); + }, +); -it.effect("counts each handled request by what happened to it", () => +it.effect("checks filters after the signature and max age, and runs only matching deliveries", () => withService(({ service, launches }) => Effect.gen(function* () { const { task } = yield* service.upsert( @@ -838,36 +1688,204 @@ it.effect("counts each handled request by what happened to it", () => header: "x-hub-signature-256", encoding: "hex", prefix: "sha256=", - secret: "github-secret", + secret: "s3cret", }, + maxDeliveryAgeMinutes: 30, + filters: [ + { path: "headers.x-github-event", op: "equals", value: "pull_request" }, + { path: "body.action", op: "in", value: ["opened", "reopened"] }, + ], }, }), ); - const before = { - accepted: yield* deliveriesCounted("accepted", "direct"), - rejected: yield* deliveriesCounted("rejected_signature", "direct"), - notFound: yield* deliveriesCounted("not_found", "direct"), - relayAccepted: yield* deliveriesCounted("accepted", "relay"), - duplicate: yield* deliveriesCounted("duplicate", "relay"), - }; - const signed = { - "content-type": "application/json", - "x-hub-signature-256": signatureFor("github-secret"), + const delivery = ( + event: string, + action: string, + options: { readonly signed?: boolean; readonly receivedAt?: string } = {}, + ) => { + const body = new TextEncoder().encode( + JSON.stringify({ action, pull_request: { url: "https://github.com/org/repo/pull/45" } }), + ); + const signature = `sha256=${NodeCrypto.createHmac("sha256", "s3cret").update(body).digest("hex")}`; + return requestFor(task, { + headers: { + "content-type": "application/json", + "x-github-event": event, + ...(options.signed === false ? {} : { "x-hub-signature-256": signature }), + }, + body, + bodyText: new TextDecoder().decode(body), + ...(options.receivedAt === undefined + ? {} + : { relayDeliveryId: "old", receivedAt: options.receivedAt }), + }); }; - yield* service.triggerWebhook(requestFor(task, { headers: signed })); - yield* Queue.take(launches); - yield* service.triggerWebhook(requestFor(task)); - yield* service.triggerWebhook(requestFor(task, { token: "wrong" })); - const relayed = requestFor(task, { headers: signed, relayDeliveryId: "relay-1" }); - yield* service.triggerWebhook(relayed); + const now = yield* DateTime.now; + const old = DateTime.formatIso(DateTime.subtract(now, { minutes: 31 })); + + // A request failing a filter is still refused first for its signature or age. + const unsigned = yield* service.triggerWebhook(delivery("push", "opened", { signed: false })); + assert.equal(unsigned._tag, "rejected_signature"); + const tooOld = yield* service.triggerWebhook(delivery("push", "opened", { receivedAt: old })); + assert.equal(tooOld._tag, "expired"); + + const wrongEvent = yield* service.triggerWebhook(delivery("push", "opened")); + const wrongAction = yield* service.triggerWebhook(delivery("pull_request", "closed")); + assert.deepInclude(wrongEvent, { _tag: "accepted", outcome: "filtered" }); + assert.deepInclude(wrongAction, { _tag: "accepted", outcome: "filtered" }); + assert.equal(yield* Queue.size(launches), 0); + const [listed] = (yield* service.list()).tasks; + assert.equal(listed?.runCount, 0); + assert.equal(listed?.lastRunStatus, "never"); + + const matched = yield* service.triggerWebhook(delivery("pull_request", "opened")); + assert.deepInclude(matched, { _tag: "accepted", outcome: "accepted" }); + assert.include((yield* Queue.take(launches)).initialMessage?.text ?? "", "pull/45"); + + const deliveries = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; + const filtered = deliveries + .filter((entry) => entry.outcome === "filtered") + .map((entry) => [entry.signatureVerified, entry.error]); + assert.sameDeepMembers(filtered, [ + [true, "Filter 1 (headers.x-github-event) did not match."], + [true, "Filter 2 (body.action) did not match."], + ]); + }), + ), +); + +const pullRequestFilterInput = () => + webhookTaskInput({ + schedule: { + type: "webhook", + filters: [{ path: "headers.x-github-event", op: "equals", value: "pull_request" }], + }, + }); +const githubEvent = (event: string) => ({ + headers: { "content-type": "application/json", "x-github-event": event }, +}); + +it.effect("a filtered relay delivery releases its claim, so a redelivery is evaluated again", () => + withService(({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* pullRequestFilterInput()); + const held = { relayDeliveryId: "relay-filtered" }; + const first = yield* service.triggerWebhook( + requestFor(task, { ...held, ...githubEvent("push") }), + ); + const again = yield* service.triggerWebhook( + requestFor(task, { ...held, ...githubEvent("push") }), + ); + assert.deepInclude(first, { _tag: "accepted", outcome: "filtered" }); + assert.deepInclude(again, { _tag: "accepted", outcome: "filtered" }); + const ran = yield* service.triggerWebhook( + requestFor(task, { ...held, ...githubEvent("pull_request") }), + ); + assert.deepInclude(ran, { _tag: "accepted", outcome: "accepted" }); yield* Queue.take(launches); - yield* service.triggerWebhook(relayed); + const replayed = yield* service.triggerWebhook( + requestFor(task, { ...held, ...githubEvent("pull_request") }), + ); + assert.deepInclude(replayed, { _tag: "accepted", outcome: "duplicate" }); + }), + ), +); - assert.equal((yield* deliveriesCounted("accepted", "direct")) - before.accepted, 1); - assert.equal((yield* deliveriesCounted("rejected_signature", "direct")) - before.rejected, 1); - assert.equal((yield* deliveriesCounted("not_found", "direct")) - before.notFound, 1); - assert.equal((yield* deliveriesCounted("accepted", "relay")) - before.relayAccepted, 1); - assert.equal((yield* deliveriesCounted("duplicate", "relay")) - before.duplicate, 1); +it.effect("leaves filtered rows out of the list for clients that only decode older outcomes", () => + withService(({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* pullRequestFilterInput()); + yield* service.triggerWebhook(requestFor(task, githubEvent("push"))); + yield* service.triggerWebhook(requestFor(task, githubEvent("pull_request"))); + yield* Queue.take(launches); + const legacy = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + assert.deepEqual( + legacy.map((delivery) => delivery.outcome), + ["accepted"], + ); + const current = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; + assert.sameMembers( + current.map((delivery) => delivery.outcome), + ["accepted", "filtered"], + ); }), ), ); + +it.effect("filters run before replay and coalescing without claiming ignored deliveries", () => { + const workers = new Map>(); + return withService( + ({ service, launches, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + filters: [{ path: "body.run", op: "equals", value: "true" }], + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.group}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string, run: boolean, value: string) => { + const bodyText = JSON.stringify({ run, group: "B", value }); + return requestFor(task, { + headers: { "delivery-id": id }, + bodyText, + body: new TextEncoder().encode(bodyText), + relayDeliveryId: id, + }); + }; + const ignored = yield* service.triggerWebhook(request("A", false, "ignored")); + assert.deepInclude(ignored, { _tag: "accepted", outcome: "filtered" }); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, 0); + const filteredId = ignored._tag === "accepted" ? ignored.deliveryId : assert.fail(); + const oldClient = yield* service + .getWebhookDelivery({ id: task.id, deliveryId: filteredId }) + .pipe(Effect.result); + assert.equal(oldClient._tag, "Failure"); + const currentClient = yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: filteredId, + acceptAnyOutcome: true, + }); + assert.equal(currentClient.delivery.outcome, "filtered"); + assert.deepInclude(yield* service.triggerWebhook(request("A", true, "first")), { + _tag: "accepted", + outcome: "accepted", + }); + assert.deepInclude( + yield* service.triggerWebhook({ + ...request("A", false, "ignored replay"), + relayDeliveryId: "ignored-replay", + }), + { _tag: "accepted", outcome: "filtered" }, + ); + assert.deepInclude(yield* service.triggerWebhook(request("B", false, "ignored update")), { + _tag: "accepted", + outcome: "filtered", + }); + assert.deepInclude(yield* service.triggerWebhook(request("B", true, "latest")), { + _tag: "accepted", + outcome: "coalesced", + }); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, "latest"); + assert.equal(yield* Queue.size(launches), 0); + }), + { workers }, + ); +}); diff --git a/apps/server/src/scheduledTasks/webhookDedupe.ts b/apps/server/src/scheduledTasks/webhookDedupe.ts new file mode 100644 index 000000000000..21d40b25ac5e --- /dev/null +++ b/apps/server/src/scheduledTasks/webhookDedupe.ts @@ -0,0 +1,128 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Keys need a deterministic digest, not random bytes. +import * as NodeCrypto from "node:crypto"; +import { ScheduledTask, ScheduledTaskError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; +import { renderWebhookPrompt, type WebhookRequest } from "./webhookTemplate.ts"; + +function key(template: string | undefined, request: WebhookRequest): string { + if (template === undefined) return ""; + const rendered = renderWebhookPrompt(template, request, "", true); + return rendered.missing.length > 0 || rendered.prompt === "" + ? "" + : NodeCrypto.createHash("sha256").update(rendered.prompt).digest("hex"); +} + +export const releaseWebhookReplay = Effect.fn("ScheduledTaskService.releaseWebhookReplay")( + function* (deliveryId: string) { + const sql = yield* SqlClient.SqlClient; + yield* sql`DELETE FROM scheduled_task_webhook_replay_keys WHERE delivery_id = ${deliveryId}`; + }, +); + +const hasReplayKey = Effect.fn(function* (taskId: string, replayKey: string, now: number) { + const sql = yield* SqlClient.SqlClient; + return ( + replayKey !== "" && + (yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys + WHERE task_id = ${taskId} AND replay_key = ${replayKey} AND expires_at > ${now}`).length > 0 + ); +}); +const claimReplayKey = Effect.fn(function* ( + taskId: string, + replayKey: string, + deliveryId: string, + now: number, +) { + if (replayKey === "") return; + const sql = yield* SqlClient.SqlClient; + yield* sql`DELETE FROM scheduled_task_webhook_replay_keys WHERE expires_at <= ${now}`; + yield* sql`INSERT INTO scheduled_task_webhook_replay_keys (task_id, replay_key, delivery_id, expires_at) + VALUES (${taskId}, ${replayKey}, ${deliveryId}, ${now + 7 * 24 * 60 * 60 * 1000})`; +}); + +export const acceptWebhookDedupe = Effect.fn("ScheduledTaskService.acceptWebhookDedupe")(function* ( + task: ScheduledTask, + request: WebhookRequest, + deliveryId: string, + now: number, + reserve: Effect.Effect, + reserveCoalescedLog: Effect.Effect, + checkCapacity: Effect.Effect, + replaceDeliveryRequest: (ownerId: string) => Effect.Effect, +) { + const sql = yield* SqlClient.SqlClient; + const dedupe = task.schedule.type === "webhook" ? task.schedule.dedupe : undefined; + if (dedupe === undefined) return "run" as const; + const replayKey = key(dedupe.replayKey, request); + if (yield* hasReplayKey(task.id, replayKey, now)) return "duplicate" as const; + const coalesceKey = key(dedupe.coalesceKey, request); + if (coalesceKey === "") return "run" as const; + const pending = yield* sql<{ + delivery_id: string; + }>`SELECT delivery_id FROM scheduled_task_webhook_pending + WHERE task_id = ${task.id} AND coalesce_key = ${coalesceKey} AND state = 'pending' AND due_at > ${now}`; + const logCoalesced = pending.length > 0 ? yield* reserveCoalescedLog : true; + if (pending.length === 0) { + yield* checkCapacity; + yield* reserve; + } + // Coalesced requests retain their validation slot, bounding writes without dropping accepted payloads. + yield* claimReplayKey(task.id, replayKey, pending[0]?.delivery_id ?? deliveryId, now); + const requestJson = yield* encodePendingRequest({ + method: request.method, + path: request.path, + query: request.query, + headers: request.headers, + bodyText: request.bodyText, + }); + if (pending.length > 0) { + yield* sql`UPDATE scheduled_task_webhook_pending SET request_json = ${requestJson}, + delivery_ids_json = json_insert(delivery_ids_json, '$[#]', ${deliveryId}) WHERE delivery_id = ${pending[0]!.delivery_id}`; + yield* replaceDeliveryRequest(pending[0]!.delivery_id); + return logCoalesced ? ("coalesced" as const) : ("coalesced_unlogged" as const); + } + // Expired windows are immutable, even if the scheduler has not dispatched them yet. + yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'dispatching' + WHERE task_id = ${task.id} AND coalesce_key = ${coalesceKey} AND state = 'pending' AND due_at <= ${now}`; + yield* sql`INSERT INTO scheduled_task_webhook_pending (delivery_id, task_id, coalesce_key, due_at, task_json, request_json, delivery_ids_json) + VALUES (${deliveryId}, ${task.id}, ${coalesceKey}, ${now + (dedupe.coalesceWindowSeconds ?? 120) * 1000}, ${yield* encodePendingTask(task)}, ${requestJson}, json_array(${deliveryId}))`; + return "pending" as const; +}); + +export const acceptImmediateWebhookDedupe = Effect.fn( + "ScheduledTaskService.acceptImmediateWebhookDedupe", +)(function* ( + task: ScheduledTask, + request: WebhookRequest, + deliveryId: string, + now: number, + reserve: Effect.Effect, +) { + const replayKey = key( + task.schedule.type === "webhook" ? task.schedule.dedupe?.replayKey : undefined, + request, + ); + if (yield* hasReplayKey(task.id, replayKey, now)) return "duplicate" as const; + yield* reserve; + yield* claimReplayKey(task.id, replayKey, deliveryId, now); + return "run" as const; +}); + +const PendingTaskJson = Schema.fromJsonString(ScheduledTask); +const encodePendingTask = Schema.encodeEffect(PendingTaskJson); +export const decodePendingTask = Schema.decodeUnknownEffect(PendingTaskJson); +const PendingRequestJson = Schema.fromJsonString( + Schema.Struct({ + method: Schema.String, + path: Schema.String, + query: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + bodyText: Schema.String, + }), +); +const encodePendingRequest = Schema.encodeEffect(PendingRequestJson); +export const decodePendingRequest = Schema.decodeUnknownEffect(PendingRequestJson); +export const dedupeError = (cause: unknown) => + new ScheduledTaskError({ message: "Could not persist webhook dedupe state.", cause }); diff --git a/apps/server/src/scheduledTasks/webhookFilter.test.ts b/apps/server/src/scheduledTasks/webhookFilter.test.ts new file mode 100644 index 000000000000..1194a1aed517 --- /dev/null +++ b/apps/server/src/scheduledTasks/webhookFilter.test.ts @@ -0,0 +1,95 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { findFailedWebhookFilter } from "./webhookFilter.ts"; +import type { WebhookRequest } from "./webhookTemplate.ts"; + +const issueComment: WebhookRequest = { + method: "POST", + path: "/api/hooks/task-1", + query: "", + headers: { "content-type": "application/json", "x-github-event": "issue_comment" }, + bodyText: JSON.stringify({ + action: "created", + issue: { number: 7, labels: [{ name: "bug" }, { name: "needs more info" }], assignees: [] }, + comment: { user: { login: "reporter", type: "User" } }, + }), +}; + +describe("findFailedWebhookFilter", () => { + it("matches a wildcard path when any element matches", () => { + assert.isNull( + findFailedWebhookFilter( + [ + { path: "body.issue.labels[].name", op: "equals", value: "needs more info" }, + { path: "body.issue.labels[].name", op: "in", value: ["needs more info", "triage"] }, + { path: "body.issue.labels[].name", op: "startsWith", value: "needs" }, + { path: "body.issue.labels[].name", op: "exists" }, + ], + issueComment, + ), + ); + assert.deepEqual( + findFailedWebhookFilter( + [{ path: "body.issue.labels[].name", op: "equals", value: "wontfix" }], + issueComment, + ), + { index: 0, path: "body.issue.labels[].name" }, + ); + }); + + it("fails notEquals on a wildcard path when any element equals the value", () => { + assert.deepEqual( + findFailedWebhookFilter( + [{ path: "body.issue.labels[].name", op: "notEquals", value: "bug" }], + issueComment, + ), + { index: 0, path: "body.issue.labels[].name" }, + ); + assert.isNull( + findFailedWebhookFilter( + [{ path: "body.issue.labels[].name", op: "notEquals", value: "wontfix" }], + issueComment, + ), + ); + }); + + it("treats an empty or missing wildcard list as nothing to match", () => { + assert.deepEqual( + findFailedWebhookFilter( + [{ path: "body.issue.assignees[].login", op: "exists" }], + issueComment, + ), + { index: 0, path: "body.issue.assignees[].login" }, + ); + assert.isNull( + findFailedWebhookFilter( + [{ path: "body.issue.assignees[].login", op: "notEquals", value: "anyone" }], + issueComment, + ), + ); + assert.deepEqual( + findFailedWebhookFilter([{ path: "body.issue.number[].x", op: "exists" }], issueComment), + { index: 0, path: "body.issue.number[].x" }, + ); + }); + + it("still compares scalars without a wildcard", () => { + assert.isNull( + findFailedWebhookFilter( + [ + { path: "body.action", op: "equals", value: "created" }, + { path: "body.comment.user.type", op: "notEquals", value: "Bot" }, + ], + issueComment, + ), + ); + // A list without a wildcard in the path only satisfies exists. + assert.deepEqual( + findFailedWebhookFilter( + [{ path: "body.issue.labels", op: "equals", value: "bug" }], + issueComment, + ), + { index: 0, path: "body.issue.labels" }, + ); + }); +}); diff --git a/apps/server/src/scheduledTasks/webhookFilter.ts b/apps/server/src/scheduledTasks/webhookFilter.ts new file mode 100644 index 000000000000..882f30036428 --- /dev/null +++ b/apps/server/src/scheduledTasks/webhookFilter.ts @@ -0,0 +1,65 @@ +import type { ScheduledTaskWebhookFilter } from "@t3tools/contracts"; + +import { webhookExpressionResolver, type WebhookRequest } from "./webhookTemplate.ts"; + +export interface FailedWebhookFilter { + readonly index: number; + readonly path: string; +} + +function scalarText(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return undefined; +} + +function matchesScalar(filter: ScheduledTaskWebhookFilter, text: string): boolean { + switch (filter.op) { + case "equals": + return text === filter.value; + case "notEquals": + return text !== filter.value; + case "in": + return filter.value.includes(text); + case "startsWith": + return text.startsWith(filter.value); + case "exists": + return true; + } +} + +/** + * Placeholders render null as missing, so filters treat it the same way. A + * path with a `[]` wildcard resolves to a list: `equals`, `in` and + * `startsWith` match when any element does, `notEquals` only when none equals + * the value, and `exists` when the list has an element. + */ +function matchesWebhookFilter(filter: ScheduledTaskWebhookFilter, value: unknown): boolean { + if (Array.isArray(value) && /\[\]/.test(filter.path)) { + const texts = value.map(scalarText).filter((text): text is string => text !== undefined); + if (filter.op === "notEquals") return texts.every((text) => text !== filter.value); + return texts.some((text) => matchesScalar(filter, text)); + } + if (filter.op === "exists") return value !== undefined && value !== null; + const text = scalarText(value); + if (text === undefined) return false; + return matchesScalar(filter, text); +} + +/** The first filter the request fails, or null when every filter matches. */ +export function findFailedWebhookFilter( + filters: ReadonlyArray, + request: WebhookRequest, +): FailedWebhookFilter | null { + if (filters.length === 0) return null; + const resolve = webhookExpressionResolver(request); + for (const [index, filter] of filters.entries()) { + if (!matchesWebhookFilter(filter, resolve(filter.path))) return { index, path: filter.path }; + } + return null; +} + +/** Filters are numbered from 1, in the order the settings form lists them. */ +export function describeFailedWebhookFilter(failed: FailedWebhookFilter): string { + return `Filter ${failed.index + 1} (${failed.path}) did not match.`; +} diff --git a/apps/server/src/scheduledTasks/webhookRoute.test.ts b/apps/server/src/scheduledTasks/webhookRoute.test.ts index 31261660c623..5ef605be0ab4 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.test.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.test.ts @@ -113,35 +113,6 @@ const post = ( ) => new Request(`http://env.local${path}`, { method: "POST", body, headers }); describe("webhook route", () => { - it("passes the raw request to the service and answers 202 with the delivery id", async () => { - let received: WebhookTriggerRequest | undefined; - const { handler, dispose } = handlerFor((request) => { - received = request; - return Effect.succeed({ - _tag: "accepted", - deliveryId: ScheduledTaskWebhookDeliveryId.make("delivery:1"), - outcome: "accepted", - }); - }); - try { - const response = await handler( - post("/api/hooks/scheduled-task%3Ahook/tok?x=1", '{"a":1}', { - "Content-Type": "application/json", - "X-GitHub-Event": "push", - }), - ); - expect(response.status).toBe(202); - expect(await response.json()).toEqual({ deliveryId: "delivery:1" }); - expect(received?.hookId).toBe("scheduled-task:hook"); - expect(received?.token).toBe("tok"); - expect(received?.query).toBe("x=1"); - expect(received?.headers["x-github-event"]).toBe("push"); - expect(received?.bodyText).toBe('{"a":1}'); - } finally { - await dispose(); - } - }); - it.live("trusts the relay's delivery id and receive time only with its signed proof", () => Effect.gen(function* () { const received: Array = []; @@ -222,6 +193,7 @@ describe("webhook route", () => { [{ _tag: "accepted", deliveryId, outcome: "accepted" }, 202, "accepted"], [{ _tag: "accepted", deliveryId, outcome: "duplicate" }, 202, "duplicate"], [{ _tag: "accepted", deliveryId, outcome: "prompt_too_long" }, 202, "prompt_too_long"], + [{ _tag: "accepted", deliveryId, outcome: "filtered" }, 202, "filtered"], [{ _tag: "not_found" }, 404, "not_found"], [{ _tag: "rejected_signature" }, 401, "rejected_signature"], [{ _tag: "disabled" }, 409, "disabled"], @@ -235,39 +207,13 @@ describe("webhook route", () => { const response = await handler(post("/api/hooks/id/tok", "{}")); expect(response.status).toBe(status); expect(response.headers.get("x-t3-hook-outcome")).toBe(outcome); + if (status === 429) expect(await response.json()).toEqual({ error: outcome }); } finally { await dispose(); } } }); - it.live("joins the relay's trace only for requests the relay forwarded", () => - Effect.gen(function* () { - const parents: Array = []; - const { handler, dispose } = handlerFor(() => - Effect.gen(function* () { - const span = yield* Effect.currentParentSpan.pipe(Effect.option); - parents.push(span._tag === "Some" ? span.value.traceId : undefined); - return { _tag: "not_found" } as const; - }), - ); - const send = (headers: Record) => - Effect.promise(() => handler(post("/api/hooks/id/tok", "{}", headers))); - const traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; - const receivedAt = "2026-10-04T10:00:00.000Z"; - const relayHeaders = { - "x-t3-relay-delivery-id": "relay-1", - "x-t3-relay-received-at": receivedAt, - }; - const proof = yield* relayProof({ deliveryId: "relay-1", receivedAt, hookId: "id" }); - yield* send({ ...relayHeaders, traceparent, "x-t3-relay-delivery": proof }); - yield* send({ ...relayHeaders, traceparent }); - yield* send({ traceparent }); - yield* Effect.promise(() => dispose()); - expect(parents).toEqual(["0af7651916cd43dd8448eb211c80319c", undefined, undefined]); - }), - ); - it("rejects oversized bodies and malformed paths before reaching the service", async () => { let calls = 0; const { handler, dispose } = handlerFor(() => { diff --git a/apps/server/src/scheduledTasks/webhookRoute.ts b/apps/server/src/scheduledTasks/webhookRoute.ts index aa1b25a6351c..2566349d273b 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.ts @@ -33,98 +33,98 @@ export const layer = HttpApiBuilder.group( * The endpoint is raw so the signature is checked over the exact body * bytes; the service checks the token and signature. */ - const handler = ({ + // Only verified relay deliveries join a trace; direct requests keep their existing context. + const handler = Effect.fnUntraced(function* ({ params, request, }: { readonly params: { readonly hookId: string; readonly token: string }; readonly request: HttpServerRequest.HttpServerRequest; - }) => - Effect.gen(function* () { - const contentLength = Number(request.headers["content-length"] ?? "0"); - if (!Number.isFinite(contentLength) || contentLength > WEBHOOK_MAX_BODY_BYTES) { - return json(413, { error: "body_too_large" }, "body_too_large"); - } - // Chunked requests carry no content-length, so the reader itself is capped. - const body = yield* request.arrayBuffer.pipe( - Effect.map((buffer) => new Uint8Array(buffer)), - Effect.provideService( - HttpIncomingMessage.MaxBodySize, - ByteSize.bytes(WEBHOOK_MAX_BODY_BYTES), - ), - Effect.option, - ); - const tooLarge = (error: string) => - Metrics.increment(Metrics.webhookDeliveriesTotal, { - outcome: "body_too_large", - source: request.headers["x-t3-relay-delivery-id"] ? "relay" : "direct", - }).pipe(Effect.as(json(413, { error }, "body_too_large"))); - if (Option.isNone(body)) return yield* tooLarge("body_too_large_or_unreadable"); - if (body.value.byteLength > WEBHOOK_MAX_BODY_BYTES) { - return yield* tooLarge("body_too_large"); - } + }) { + const contentLength = Number(request.headers["content-length"] ?? "0"); + if (!Number.isFinite(contentLength) || contentLength > WEBHOOK_MAX_BODY_BYTES) { + return json(413, { error: "body_too_large" }, "body_too_large"); + } + // Chunked requests carry no content-length, so the reader itself is capped. + const body = yield* request.arrayBuffer.pipe( + Effect.map((buffer) => new Uint8Array(buffer)), + Effect.provideService( + HttpIncomingMessage.MaxBodySize, + ByteSize.bytes(WEBHOOK_MAX_BODY_BYTES), + ), + Effect.option, + ); + const tooLarge = (error: string) => + Metrics.increment(Metrics.webhookDeliveriesTotal, { + outcome: "body_too_large", + source: request.headers["x-t3-relay-delivery-id"] ? "relay" : "direct", + }).pipe(Effect.as(json(413, { error }, "body_too_large"))); + if (Option.isNone(body)) return yield* tooLarge("body_too_large_or_unreadable"); + if (body.value.byteLength > WEBHOOK_MAX_BODY_BYTES) { + return yield* tooLarge("body_too_large"); + } - const headers: Record = {}; - for (const [name, value] of Object.entries(request.headers)) { - if (typeof value === "string") headers[name.toLowerCase()] = value; - } - const queryIndex = request.url.indexOf("?"); - // The relay's delivery id and receive time count only with its signed - // proof; the URL can also be called directly. The receive time matters - // for requests the relay held while we were offline. - const relay = yield* relayDeliveryProof.verify({ headers, hookId: params.hookId }); - const relayDeliveryId = Option.isSome(relay) ? relay.value.deliveryId : undefined; - const relayReceivedAt = Option.isSome(relay) ? relay.value.receivedAt : undefined; + const headers: Record = {}; + for (const [name, value] of Object.entries(request.headers)) { + if (typeof value === "string") headers[name.toLowerCase()] = value; + } + const queryIndex = request.url.indexOf("?"); + // The relay's delivery id and receive time count only with its signed + // proof; the URL can also be called directly. The receive time matters + // for requests the relay held while we were offline. + const relay = yield* relayDeliveryProof.verify({ headers, hookId: params.hookId }); + const relayDeliveryId = Option.isSome(relay) ? relay.value.deliveryId : undefined; + const relayReceivedAt = Option.isSome(relay) ? relay.value.receivedAt : undefined; - // A relay delivery joins the relay's trace, and goes to the T3 Connect - // tracer with it. Anyone else's traceparent is never trusted. - const relayParent = Option.isSome(relay) - ? HttpTraceContext.fromHeaders(request.headers) - : Option.none(); - const result = yield* scheduledTasks - .triggerWebhook({ - hookId: params.hookId, - token: params.token, - method: request.method, - path: `${ScheduledTaskService.WEBHOOK_ROUTE_PREFIX}/${encodeURIComponent(params.hookId)}`, - query: queryIndex === -1 ? "" : request.url.slice(queryIndex + 1), - headers, - body: body.value, - bodyText: new TextDecoder().decode(body.value), - ...(relayDeliveryId ? { relayDeliveryId } : {}), - ...(relayReceivedAt ? { receivedAt: relayReceivedAt } : {}), - }) - .pipe( - Option.isSome(relayParent) - ? (effect) => - effect.pipe(Effect.withParentSpan(relayParent.value), withRelayClientTracing) - : (effect) => effect, - Effect.catchCause((cause) => - Effect.logWarning("Webhook delivery failed").pipe( - Effect.annotateLogs({ hookId: params.hookId }), - Effect.andThen(Effect.logDebug("Webhook delivery failure cause", { cause })), - Effect.as({ _tag: "error" as const }), - ), + // A relay delivery joins the relay's trace, and goes to the T3 Connect + // tracer with it. Anyone else's traceparent is never trusted. + const relayParent = Option.isSome(relay) + ? HttpTraceContext.fromHeaders(request.headers) + : Option.none(); + const result = yield* scheduledTasks + .triggerWebhook({ + hookId: params.hookId, + token: params.token, + method: request.method, + path: `${ScheduledTaskService.WEBHOOK_ROUTE_PREFIX}/${encodeURIComponent(params.hookId)}`, + query: queryIndex === -1 ? "" : request.url.slice(queryIndex + 1), + headers, + body: body.value, + bodyText: new TextDecoder().decode(body.value), + ...(relayDeliveryId ? { relayDeliveryId } : {}), + ...(relayReceivedAt ? { receivedAt: relayReceivedAt } : {}), + }) + .pipe( + Option.isSome(relayParent) + ? (effect) => + effect.pipe(Effect.withParentSpan(relayParent.value), withRelayClientTracing) + : (effect) => effect, + Effect.catchCause((cause) => + Effect.logWarning("Webhook delivery failed").pipe( + Effect.annotateLogs({ hookId: params.hookId }), + Effect.andThen(Effect.logDebug("Webhook delivery failure cause", { cause })), + Effect.as({ _tag: "error" as const }), ), - ); + ), + ); - switch (result._tag) { - case "accepted": - return json(202, { deliveryId: result.deliveryId }, result.outcome); - case "not_found": - return json(404, { error: "hook_not_found" }, "not_found"); - case "rejected_signature": - return json(401, { error: "invalid_signature" }, "rejected_signature"); - case "disabled": - return json(409, { error: "hook_disabled" }, "disabled"); - case "rate_limited": - return json(429, { error: "rate_limited" }, result.outcome); - case "expired": - return json(410, { error: "delivery_too_old" }, "expired"); - case "error": - return json(500, { error: "internal_error" }, "error"); - } - }); + switch (result._tag) { + case "accepted": + return json(202, { deliveryId: result.deliveryId }, result.outcome); + case "not_found": + return json(404, { error: "hook_not_found" }, "not_found"); + case "rejected_signature": + return json(401, { error: "invalid_signature" }, "rejected_signature"); + case "disabled": + return json(409, { error: "hook_disabled" }, "disabled"); + case "rate_limited": + return json(429, { error: result.outcome }, result.outcome); + case "expired": + return json(410, { error: "delivery_too_old" }, "expired"); + case "error": + return json(500, { error: "internal_error" }, "error"); + } + }); return handlers .handleRaw("webhookPost", handler) .handleRaw("webhookPut", handler) diff --git a/apps/server/src/scheduledTasks/webhookTemplate.test.ts b/apps/server/src/scheduledTasks/webhookTemplate.test.ts index 6f8f3474fb51..6aa23e12b036 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.test.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.test.ts @@ -77,34 +77,6 @@ describe("renderWebhookPrompt", () => { assert.deepEqual(collectComposerContextReferences(rendered.prompt), []); }); - it("reads headers case-insensitively, query parameters and array indexes", () => { - const rendered = renderWebhookPrompt( - "{{ headers.X-GitHub-Event }} #{{body.pull_request.number}} {{body.pull_request.labels.0}} {{query.source}}", - githubPullRequest, - "id", - ); - assert.equal( - rendered.prompt, - `pull_request #45 bug github${NOTE}`, - ); - }); - - it("renders objects as JSON and the raw body and request on request", () => { - const rendered = renderWebhookPrompt( - "{{body.pull_request.labels}}|{{body}}", - githubPullRequest, - "id", - ); - assert.equal( - rendered.prompt, - `[\n "bug"\n]|${githubPullRequest.bodyText}${NOTE}`, - ); - const request = renderWebhookPrompt("{{request}}", githubPullRequest, "id").prompt; - assert.isTrue(request.startsWith("POST /api/hooks/task-1?source=github\n")); - assert.include(request, "x-github-event: pull_request"); - assert.isTrue(request.endsWith(`${githubPullRequest.bodyText}${NOTE}`)); - }); - it("renders missing fields empty and reports them once", () => { const rendered = renderWebhookPrompt( "a={{body.nope}} b={{body.nope}} c={{headers.x-missing}} d={{unknown}}", @@ -115,22 +87,6 @@ describe("renderWebhookPrompt", () => { assert.deepEqual(rendered.missing, ["body.nope", "headers.x-missing", "unknown"]); }); - it("leaves empty values unframed and does not report them missing", () => { - const rendered = renderWebhookPrompt("{{body}}", { ...githubPullRequest, bodyText: "" }, "id"); - assert.equal(rendered.prompt, ""); - assert.deepEqual(rendered.missing, []); - }); - - it("trims placeholder whitespace before resolving and reporting missing fields", () => { - const rendered = renderWebhookPrompt( - "{{\tbody.action\n}}|{{ body.nope }}|{{\nbody.nope\t}}|{{ \t\n }}", - githubPullRequest, - "id", - ); - assert.equal(rendered.prompt, `opened|||${NOTE}`); - assert.deepEqual(rendered.missing, ["body.nope", ""]); - }); - it("preserves unclosed whitespace-heavy placeholders and resolves later valid ones", () => { const unfinished = `Notify {{${" ".repeat(1024)}body`; const rendered = renderWebhookPrompt( @@ -209,6 +165,23 @@ describe("renderWebhookPrompt", () => { ); }); + it("renders a [] wildcard as the list of matching values", () => { + const rendered = renderWebhookPrompt( + "Labels: {{body.pull_request.labels[]}} Names: {{body.reviewers[].login}}", + { + ...githubPullRequest, + bodyText: JSON.stringify({ + pull_request: { labels: ["bug", "ui"] }, + reviewers: [{ login: "ada" }, { name: "no login" }, { login: "bob" }], + }), + }, + "id", + true, + ); + assert.equal(rendered.prompt, 'Labels: [\n "bug",\n "ui"\n] Names: [\n "ada",\n "bob"\n]'); + assert.deepEqual(rendered.missing, []); + }); + it("does not resolve inherited object properties", () => { const rendered = renderWebhookPrompt( "{{body.constructor}}{{body.__proto__}}{{headers.constructor}}{{headers.__proto__}}", diff --git a/apps/server/src/scheduledTasks/webhookTemplate.ts b/apps/server/src/scheduledTasks/webhookTemplate.ts index b129704f0abe..84e2599dc0d7 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.ts @@ -83,12 +83,30 @@ function parseBody(request: WebhookRequest): unknown { } } +const WILDCARD = /^(.*)\[\]$/; + +/** + * Walks `segments` from `root`. A segment ending in `[]` names an array and + * the rest of the path applies to each element, so `labels[].name` resolves + * to the list of every label's name. Elements without the rest of the path + * are dropped; a wildcard on a non-array resolves to undefined. + */ function lookup(root: unknown, segments: ReadonlyArray): unknown { let current = root; - for (const segment of segments) { + for (const [index, segment] of segments.entries()) { if (current === null || typeof current !== "object") return undefined; - if (!Object.hasOwn(current, segment)) return undefined; - current = (current as Record)[segment]; + const wildcard = WILDCARD.exec(segment); + const key = wildcard ? wildcard[1]! : segment; + if (!Object.hasOwn(current, key)) return undefined; + current = (current as Record)[key]; + if (!wildcard) continue; + if (!Array.isArray(current)) return undefined; + const rest = segments.slice(index + 1); + return current.flatMap((element) => { + const value = rest.length === 0 ? element : lookup(element, rest); + if (value === undefined) return []; + return rest.some((part) => WILDCARD.test(part)) && Array.isArray(value) ? value : [value]; + }); } return current; } @@ -112,16 +130,17 @@ function formatWebhookRequest(request: WebhookRequest): string { ].join("\n"); } -export function renderWebhookPrompt( - template: string, +/** + * Resolves a placeholder expression (`body.action`, `headers.x-github-event`) + * against the request. Webhook filters share it, so both read the same value. + */ +export function webhookExpressionResolver( request: WebhookRequest, - frameId: string, -): RenderedWebhookPrompt { - const missing: string[] = []; +): (expression: string) => unknown { let body: { parsed: unknown } | undefined; const parsedBody = () => (body ??= { parsed: parseBody(request) }).parsed; - const resolve = (expression: string): unknown => { + return (expression) => { const [root, ...segments] = expression.split("."); switch (root) { case "request": @@ -143,6 +162,16 @@ export function renderWebhookPrompt( return undefined; } }; +} + +export function renderWebhookPrompt( + template: string, + request: WebhookRequest, + frameId: string, + raw = false, +): RenderedWebhookPrompt { + const missing: string[] = []; + const resolve = webhookExpressionResolver(request); const tag = `webhook-data-${frameId}`; let framed = false; @@ -150,12 +179,13 @@ export function renderWebhookPrompt( const expression = rawExpression.trim(); const value = resolve(expression); const text = value === undefined || value === null ? undefined : stringify(value); - if (text === undefined) { + if (text === undefined || (raw && text === "")) { if (!missing.includes(expression)) missing.push(expression); return ""; } // An empty value carries no request data, so it needs no frame or note. if (text === "") return ""; + if (raw) return text; framed = true; return `<${tag}>${text.replace(MENTION, "$&\u2060")}`; }); diff --git a/apps/server/src/scheduledTasks/webhookVerification.test.ts b/apps/server/src/scheduledTasks/webhookVerification.test.ts index b5c2b676084c..330128bfb1a0 100644 --- a/apps/server/src/scheduledTasks/webhookVerification.test.ts +++ b/apps/server/src/scheduledTasks/webhookVerification.test.ts @@ -12,11 +12,6 @@ const hmac = () => NodeCrypto.createHmac("sha256", secret).update(body); describe("verifyWebhookSignature", () => { const github = { header: "x-hub-signature-256", encoding: "hex", prefix: "sha256=" } as const; - it("accepts a GitHub-style hex signature with prefix", () => { - const headers = { "x-hub-signature-256": `sha256=${hmac().digest("hex")}` }; - assert.isTrue(verifyWebhookSignature({ signature: github, secret, headers, body })); - }); - it("rejects a tampered body, a wrong secret, a missing header and a missing prefix", () => { const valid = `sha256=${hmac().digest("hex")}`; const tampered = new TextEncoder().encode('{"action":"closed"}'); diff --git a/apps/server/src/scheduling/Scheduler.integration.test.ts b/apps/server/src/scheduling/Scheduler.integration.test.ts deleted file mode 100644 index 1409697a7988..000000000000 --- a/apps/server/src/scheduling/Scheduler.integration.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { assert, expect, it } from "@effect/vitest"; -import { - CommandId, - DEFAULT_SERVER_SETTINGS, - ProjectId, - ProviderInstanceId, - RunId, - ThreadId, - type OrchestrationV2ServerCommand, - type OrchestrationV2ThreadShell, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Queue from "effect/Queue"; -import * as Ref from "effect/Ref"; -import * as SqlClient from "effect/sql/SqlClient"; -import * as TestClock from "effect/testing/TestClock"; - -import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; -import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; -import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; -import * as UsageLimitRecoveryWorker from "../orchestration-v2/UsageLimitRecoveryWorker.ts"; -import * as SqlitePersistence from "../persistence/Sqlite.ts"; -import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts"; -import * as SecretRequests from "../secrets/SecretRequests.ts"; -import * as ServerSettings from "../serverSettings.ts"; -import * as Scheduler from "./Scheduler.ts"; - -it.effect.each(["on time", "after restart"])( - "runs Scheduled Tasks and a persisted limit retry through the same scheduler %s", - (scenario) => - Effect.gen(function* () { - const now = yield* DateTime.now; - const resetAt = DateTime.formatIso(DateTime.add(now, { seconds: 60 })); - const thread: OrchestrationV2ThreadShell = { - id: ThreadId.make("thread:limited"), - projectId: ProjectId.make("project:test"), - title: "Limited", - providerInstanceId: ProviderInstanceId.make("codex"), - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - createdBy: "user", - creationSource: "web", - branch: null, - worktreePath: null, - lineage: { - rootThreadId: ThreadId.make("thread:limited"), - parentThreadId: null, - relationshipToParent: null, - }, - forkedFrom: null, - activeProviderThreadId: null, - latestRunId: RunId.make("run:limited"), - latestRunCompletedAt: now, - activeRunId: null, - status: "failed", - lastErrorClass: "usage_limit", - usageLimitResetAt: resetAt, - pendingRuntimeRequest: null, - latestVisibleMessage: null, - latestUserMessageAt: now, - hasActionableProposedPlan: false, - itemCount: 1, - visibleItemCount: 1, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - pinnedAt: null, - deletedAt: null, - limitRecovery: { - runId: RunId.make("run:limited"), - resetAt, - autoResume: true, - requestId: CommandId.make("recovery:choice"), - }, - }; - if (scenario === "after restart") { - yield* TestClock.adjust("65 seconds"); - } - const current = yield* Ref.make(thread); - const commands = yield* Ref.make>([]); - const receipts = yield* Queue.unbounded<"task" | "retry">(); - const layerDependencies = Layer.mergeAll( - NodeCrypto.layer, - Layer.mock(ThreadLaunchService.ThreadLaunchService)({ - launch: () => - Queue.offer(receipts, "task").pipe( - Effect.andThen(Effect.die("fixture dispatch failure")), - ), - }), - Layer.mock(ThreadManagementService.ThreadManagementService)({ - dispatch: (command) => - Ref.update(commands, (all) => [...all, command]).pipe( - Effect.andThen( - Ref.update(current, (shell) => ({ ...shell, status: "running" as const })), - ), - Effect.andThen(Queue.offer(receipts, "retry")), - Effect.as({ sequence: 1, storedEvents: [] }), - ), - }), - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getLimitRecoveryCandidates: () => - Ref.get(current).pipe( - Effect.map((shell) => (shell.status === "failed" ? [shell] : [])), - ), - }), - Layer.mock(ServerSettings.ServerSettingsService)({ - getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), - }), - Layer.mock(SecretRequests.SecretRequests)({}), - ); - const layerWorkers = Layer.mergeAll( - ScheduledTasks.layer, - UsageLimitRecoveryWorker.layer, - ).pipe(Layer.provide(layerDependencies), Layer.provide(Scheduler.layer)); - yield* Effect.gen(function* () { - const tasks = yield* ScheduledTasks.ScheduledTaskService; - const { task } = yield* tasks.upsert({ - title: "Scheduled work", - prompt: "Run scheduled work", - enabled: true, - schedule: { type: "interval", everyMs: 60_000 }, - projectId: thread.projectId, - workspaceStrategy: { type: "root" }, - modelSelection: thread.modelSelection, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - }); - const sql = yield* SqlClient.SqlClient; - yield* sql`UPDATE scheduled_tasks SET next_run_at = ${resetAt} WHERE task_id = ${task.id}`; - if (scenario === "on time") { - yield* TestClock.adjust("55 seconds"); - assert.deepEqual(yield* Ref.get(commands), []); - } - yield* TestClock.adjust("5 seconds"); - assert.deepEqual([yield* Queue.take(receipts), yield* Queue.take(receipts)].sort(), [ - "retry", - "task", - ]); - const delivered = yield* Ref.get(commands); - assert.equal(delivered.length, 1); - expect(delivered[0]).toMatchObject({ - type: "message.dispatch", - usageLimitContinuationOfRunId: thread.latestRunId, - usageLimitRecoveryRequestId: thread.limitRecovery!.requestId, - text: "Continue where you left off.", - }); - }).pipe(Effect.provide(layerWorkers)); - }).pipe(Effect.provide(SqlitePersistence.layerMemory)), -); diff --git a/apps/server/src/secrets/SecretRequests.test.ts b/apps/server/src/secrets/SecretRequests.test.ts index b751b0b205c5..ffb8cd314d87 100644 --- a/apps/server/src/secrets/SecretRequests.test.ts +++ b/apps/server/src/secrets/SecretRequests.test.ts @@ -7,17 +7,14 @@ import { ThreadId, TurnItemId, } from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; -import * as TestClock from "effect/testing/TestClock"; import * as Tracer from "effect/Tracer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import * as ServerConfig from "../config.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as SecretRequests from "./SecretRequests.ts"; @@ -211,24 +208,6 @@ it.effect("a save whose record failed can be saved again", () => ), ); -it.effect("a save whose record and cleanup both failed is finished by saving again", () => - withService( - ({ service }) => - Effect.gen(function* () { - yield* service - .answer({ threadId, turnItemId, answer: { type: "save", secret: "ghp_secret" } }) - .pipe(Effect.flip); - yield* service.answer({ - threadId, - turnItemId, - answer: { type: "save", secret: "ghp_secret" }, - }); - assert.isTrue(Option.isSome(yield* service.savedRef({ threadId, turnItemId }))); - }), - { failedRecords: 1, removeFails: true }, - ), -); - it.effect("a ref only works in the project it was entered for", () => withService(({ service }) => Effect.gen(function* () { @@ -343,48 +322,6 @@ it.effect("refuses an answer once the agent that asked has stopped", () => ), ); -it.effect("drops values nobody used once they expire, and keeps the rest", () => - Effect.gen(function* () { - const store = yield* ServerSecretStore.ServerSecretStore; - const encode = (savedAt: number) => - new TextEncoder().encode( - `{"projectId":"project-1","value":"ghp_secret","savedAt":${savedAt}}`, - ); - yield* TestClock.adjust("2 days"); - const now = yield* Clock.currentTimeMillis; - const stale = `secret-request-${"a".repeat(32)}`; - const fresh = `secret-request-${"b".repeat(32)}`; - yield* store.set(stale, encode(now - 25 * 60 * 60 * 1000)); - yield* store.set(fresh, encode(now)); - yield* store.set("unrelated", new Uint8Array([1])); - - yield* Effect.gen(function* () { - yield* SecretRequests.SecretRequests; - }).pipe( - Effect.provide( - SecretRequests.layer.pipe( - Layer.provide(Layer.mock(ThreadManagementService.ThreadManagementService)({})), - ), - ), - Effect.scoped, - ); - - assert.isTrue(Option.isNone(yield* store.get(stale))); - assert.isTrue(Option.isSome(yield* store.get(fresh))); - assert.isTrue(Option.isSome(yield* store.get("unrelated"))); - assert.isTrue(Option.isSome(yield* store.get("secret-request-salt"))); - }).pipe( - Effect.provide( - ServerSecretStore.layer.pipe( - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-requests-" }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ), - ), -); - it.effect("a used value that cannot be deleted is not handed out", () => withService( ({ service }) => diff --git a/apps/server/src/secrets/SecretRequests.ts b/apps/server/src/secrets/SecretRequests.ts index fadad6ea743b..4470c257f67b 100644 --- a/apps/server/src/secrets/SecretRequests.ts +++ b/apps/server/src/secrets/SecretRequests.ts @@ -73,8 +73,8 @@ const make = Effect.gen(function* () { yield* store.getOrCreateRandom("secret-request-salt", 32).pipe(Effect.orDie), ).toString("hex"); - const answer: SecretRequests["Service"]["answer"] = (input) => - Effect.gen(function* () { + const answer: SecretRequests["Service"]["answer"] = Effect.fn("SecretRequests.answer")( + function* (input) { yield* Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId, "secret_request.answer": input.answer.type, @@ -159,14 +159,16 @@ const make = Effect.gen(function* () { yield* removeLogged(storeName(refFor(salt, input.threadId, item.id))); return yield* new SecretRequestError({ reason: "agent_stopped" }); } - }).pipe(Effect.withSpan("SecretRequests.answer")); + }, + ); - const savedRef: SecretRequests["Service"]["savedRef"] = (input) => - Effect.gen(function* () { + const savedRef: SecretRequests["Service"]["savedRef"] = Effect.fn("SecretRequests.savedRef")( + function* (input) { const ref = refFor(salt, input.threadId, input.turnItemId); const stored = yield* store.get(storeName(ref)).pipe(Effect.orElseSucceed(Option.none)); return Option.map(stored, () => ref); - }); + }, + ); const removeLogged = (name: string) => store.remove(name).pipe( @@ -191,30 +193,32 @@ const make = Effect.gen(function* () { Effect.withSpan("SecretRequests.consume"), ); - const consumeRef = (input: { readonly ref: SecretRef; readonly projectId: ProjectId }) => - Effect.gen(function* () { - if (!REF_PATTERN.test(input.ref)) - return yield* new SecretRequestError({ reason: "invalid_ref" }); - const stored = yield* store - .get(storeName(input.ref)) - .pipe(Effect.mapError((cause) => new SecretRequestError({ reason: "read_failed", cause }))); - const decoded = Option.flatMap(stored, (bytes) => - decodeStored(new TextDecoder().decode(bytes)), - ); - if (Option.isNone(decoded) || decoded.value.projectId !== input.projectId) { - return yield* new SecretRequestError({ reason: "ref_unavailable" }); - } - if ((yield* Clock.currentTimeMillis) - decoded.value.savedAt > SECRET_REF_TTL_MS) { - yield* removeLogged(storeName(input.ref)); - return yield* new SecretRequestError({ reason: "ref_expired" }); - } - // One use: the value moves into whatever consumed it. If it cannot be - // deleted, it is not handed out, so a ref is never used twice. - if (!(yield* removeLogged(storeName(input.ref)))) { - return yield* new SecretRequestError({ reason: "consume_failed" }); - } - return decoded.value.value; - }); + const consumeRef = Effect.fn("SecretRequests.consumeRef")(function* (input: { + readonly ref: SecretRef; + readonly projectId: ProjectId; + }) { + if (!REF_PATTERN.test(input.ref)) + return yield* new SecretRequestError({ reason: "invalid_ref" }); + const stored = yield* store + .get(storeName(input.ref)) + .pipe(Effect.mapError((cause) => new SecretRequestError({ reason: "read_failed", cause }))); + const decoded = Option.flatMap(stored, (bytes) => + decodeStored(new TextDecoder().decode(bytes)), + ); + if (Option.isNone(decoded) || decoded.value.projectId !== input.projectId) { + return yield* new SecretRequestError({ reason: "ref_unavailable" }); + } + if ((yield* Clock.currentTimeMillis) - decoded.value.savedAt > SECRET_REF_TTL_MS) { + yield* removeLogged(storeName(input.ref)); + return yield* new SecretRequestError({ reason: "ref_expired" }); + } + // One use: the value moves into whatever consumed it. If it cannot be + // deleted, it is not handed out, so a ref is never used twice. + if (!(yield* removeLogged(storeName(input.ref)))) { + return yield* new SecretRequestError({ reason: "consume_failed" }); + } + return decoded.value.value; + }); const sweepExpired = Effect.gen(function* () { if (store.directory === undefined) return; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 59daedc15107..48d701103c07 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,13 +1,12 @@ import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; -import * as Clock from "effect/Clock"; import * as Random from "effect/Random"; -import * as Semaphore from "effect/Semaphore"; import * as DatabaseStorage from "./persistence/DatabaseStorage.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; import * as PullRequestWatchReactor from "./orchestration-v2/PullRequestWatchReactor.ts"; import * as DelegatedTaskQuestionReactor from "./orchestration-v2/DelegatedTaskQuestionReactor.ts"; +// Effect wraps this raw Node HTTP server after its startup and write-error guards attach. // @effect-diagnostics nodeBuiltinImport:off import * as NodeHttp from "node:http"; @@ -21,7 +20,6 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; -import * as Schedule from "effect/Schedule"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/http"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; @@ -30,7 +28,10 @@ import * as HostPowerMonitor from "./background/HostPowerMonitor.ts"; import * as ServerConfig from "./config.ts"; import { withUntracedRequests } from "./http.ts"; import * as ServerHttp from "./http.ts"; -import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; +import { + guardHttpResponseWriteErrors, + rejectRequestsUntilServing, +} from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; import * as Ws from "./ws.ts"; import * as AgentScopeLive from "./process/agentScope.ts"; @@ -139,13 +140,15 @@ import { import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as CloudHttp from "./cloud/http.ts"; import * as CloudLink from "./cloud/CloudLink.ts"; +import * as ConnectReachability from "./cloud/ConnectReachability.ts"; import { pendingServiceUpdateExists } from "./cloud/updateHandoff.ts"; import * as RelayTracing from "./cloud/relayTracing.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import { MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER, - MANAGED_TUNNEL_RECOVERY_COOLDOWN, + makeManagedTunnelRecovery, managedTunnelStartupAction, + retryDesiredLinkReconcile, retryManagedTunnelRegistration, } from "./cloud/managedTunnelStartup.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; @@ -161,23 +164,26 @@ import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryR import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts"; import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; +import * as ProcessGroupMetrics from "./resourceTelemetry/ProcessGroupMetrics.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as AntigravityUsage from "./provider/Drivers/AntigravityUsage.ts"; import * as CursorAccountReader from "@t3tools/provider-cursor/server/CursorAccountReader"; import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain"; import * as CursorUsageAccounts from "@t3tools/provider-cursor/server/CursorUsageAccounts"; import * as UsageService from "./usage/UsageService.ts"; +import * as CodexUsageHistory from "./usage/CodexUsageHistory.ts"; import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; -import * as ResourceCleanupService from "./orchestration-v2/ResourceCleanupService.ts"; import * as ThreadSettlementService from "./orchestration-v2/ThreadSettlementService.ts"; +import * as IdleCompactionService from "./orchestration-v2/IdleCompactionService.ts"; import * as ThreadPullRequestService from "./orchestration-v2/ThreadPullRequestService.ts"; import * as RunFinalizationService from "./orchestration-v2/RunFinalizationService.ts"; import * as EventStoreV2 from "./orchestration-v2/EventStore.ts"; import * as ProjectionMaintenance from "./orchestration-v2/ProjectionMaintenance.ts"; import * as ProjectionStoreV2 from "./orchestration-v2/ProjectionStore.ts"; import { makePersistedServerRuntimeState } from "./serverRuntimeState.ts"; +import { loopbackUrlHostForAddress } from "./startupAccess.ts"; import * as OrchestrationHttp from "./orchestration-v2/http.ts"; import * as ProjectHttp from "./project/http.ts"; import * as NetService from "@t3tools/shared/Net"; @@ -262,6 +268,7 @@ const layerUsage = UsageService.layer.pipe( Layer.provide( Layer.mergeAll( AntigravityUsage.layer, + CodexUsageHistory.layer, CursorUsageAccounts.layer.pipe( Layer.provide(CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer))), ), @@ -275,6 +282,7 @@ const layerResourceDiagnostics = Layer.mergeAll( layerResourceTelemetry, ProcessDiagnostics.layer.pipe(Layer.provide(layerResourceTelemetry)), ProcessResourceMonitor.layer.pipe(Layer.provide(layerResourceTelemetry)), + ProcessGroupMetrics.layer.pipe(Layer.provide(layerResourceTelemetry)), ); const layerRelayClient = Layer.unwrap( @@ -287,16 +295,19 @@ const layerRelayClient = Layer.unwrap( const layerHttpServer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - return NodeHttpServer.layer(() => guardHttpResponseWriteErrors(NodeHttp.createServer()), { - host: config.host ?? "127.0.0.1", - port: config.port, - gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, - // Context takeover stays enabled (ws default) so the compression - // window is shared across frames — that also makes small frames cheap - // to compress, so no size threshold is set (ws only honors - // `threshold` when context takeover is disabled). - websocket: { perMessageDeflate: true }, - }); + return NodeHttpServer.layer( + () => rejectRequestsUntilServing(guardHttpResponseWriteErrors(NodeHttp.createServer())), + { + host: config.host ?? "127.0.0.1", + port: config.port, + gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, + // Context takeover stays enabled (ws default) so the compression + // window is shared across frames — that also makes small frames cheap + // to compress, so no size threshold is set (ws only honors + // `threshold` when context takeover is disabled). + websocket: { perMessageDeflate: true }, + }, + ); }), ); @@ -394,6 +405,7 @@ const layerVcs = Layer.empty.pipe( Layer.provideMerge( VcsStatusBroadcaster.layer.pipe( Layer.provide(layerGitWorkflow), + Layer.provide(VcsStatusBroadcaster.layerAutomaticFetchInterval), // The orchestration runtime also consumes the broadcaster (run // finalization), so the policy cannot read the store from the runtime's // output. @@ -488,7 +500,6 @@ const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe( Layer.provide(ProviderEventIngestor.layerAnalytics), Layer.provide(layerCheckpointStore), Layer.provide(layerGitWorkflow), - Layer.provide(ResourceCleanupService.layer), Layer.provide( RunFinalizationService.layerObserver.pipe( Layer.provide(ProjectionStoreV2.layer), @@ -507,6 +518,10 @@ const layerThreadSettlementWorker = Layer.effectDiscard( ThreadSettlementService.make.pipe(Effect.flatMap((service) => service.start())), ).pipe(Layer.provide(layerPullRequestService), Layer.provide(ProjectionStoreV2.layer)); +const layerIdleCompactionWorker = Layer.effectDiscard( + IdleCompactionService.make.pipe(Effect.flatMap((service) => service.start())), +).pipe(Layer.provide(ProjectionStoreV2.layer)); + const layerThreadPullRequestWorker = Layer.effectDiscard( ThreadPullRequestService.make.pipe(Effect.flatMap((service) => service.start())), ).pipe(Layer.provide(layerPullRequestService)); @@ -547,6 +562,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( AgentAwarenessRelay.layer, HeldHooksWaker.layer, layerThreadSettlementWorker, + layerIdleCompactionWorker, StorageCleanup.layer.pipe(Layer.provide(ProjectionStoreV2.layer)), DatabaseStorage.layer.pipe( Layer.provide(ProjectionMaintenance.layer), @@ -777,7 +793,7 @@ const layerMakeServer = Layer.unwrap( return yield* ensureTailscaleServe({ localPort, servePort: config.tailscaleServePort, - localHost: "127.0.0.1", + localHost: loopbackUrlHostForAddress(address), }).pipe( Effect.as({ localPort, servePort: config.tailscaleServePort }), Effect.tap(() => @@ -847,44 +863,28 @@ const layerMakeServer = Layer.unwrap( const server = yield* HttpServer.HttpServer; const address = server.address; if (typeof address === "string" || !("port" in address)) return; - const localOrigin = `http://127.0.0.1:${address.port}`; + const localOrigin = `http://${loopbackUrlHostForAddress(address)}:${address.port}`; const endpointRuntime = yield* CloudManagedEndpointRuntime.CloudManagedEndpointRuntime; - const recoveryLock = yield* Semaphore.make(1); - let lastRecoveryAtMillis = 0; + const runManagedTunnelRecovery = yield* makeManagedTunnelRecovery({ + recover: (config: RelayManagedEndpointRuntimeConfig) => + cloudLink.recoverManagedTunnel(localOrigin, config, { + retryRuntimeFailures: true, + }), + isRetryable: (error) => + CloudLink.shouldRetryCloudLink(error) && + error._tag !== "CloudLinkEndpointUnavailableError", + }); const recoverManagedTunnel = (config: RelayManagedEndpointRuntimeConfig) => - recoveryLock.withPermits(1)( - Effect.gen(function* () { - const elapsed = (yield* Clock.currentTimeMillis) - lastRecoveryAtMillis; - const wait = Duration.toMillis(MANAGED_TUNNEL_RECOVERY_COOLDOWN) - elapsed; - if (wait > 0) yield* Effect.sleep(Duration.millis(wait)); - lastRecoveryAtMillis = yield* Clock.currentTimeMillis; - }).pipe( - Effect.andThen( - cloudLink.recoverManagedTunnel(localOrigin, config, { - retryRuntimeFailures: true, - }), - ), - Effect.retry({ - while: (error) => - CloudLink.shouldRetryCloudLink(error) && - error._tag !== "CloudLinkEndpointUnavailableError", - schedule: Schedule.exponential("1 second").pipe( - Schedule.modifyDelay(({ duration }) => - Effect.succeed(Duration.min(duration, Duration.seconds(30))), - ), - Schedule.jittered, - ), - }), - Effect.tap((recovered) => - recovered ? Effect.logInfo("T3 Connect managed tunnel recovered") : Effect.void, - ), - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.interrupt - : Effect.logWarning("Failed to recover the T3 Connect managed tunnel", { - cause, - }), - ), + runManagedTunnelRecovery(config).pipe( + Effect.tap((recovered) => + recovered ? Effect.logInfo("T3 Connect managed tunnel recovered") : Effect.void, + ), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("Failed to recover the T3 Connect managed tunnel", { + cause, + }), ), ); yield* endpointRuntime.recoveryRequests.pipe( @@ -986,15 +986,13 @@ const layerMakeServer = Layer.unwrap( const reconciledMode = yield* cloudLink .reconcileDesiredLinkIfStillDesired(localOrigin) .pipe( - Effect.retry({ - while: CloudLink.shouldRetryCloudLink, - schedule: Schedule.exponential("1 second").pipe( - Schedule.modifyDelay(({ duration }) => - Effect.succeed(Duration.min(duration, Duration.seconds(30))), + (reconcile) => + retryDesiredLinkReconcile(reconcile, CloudLink.shouldRetryCloudLink, (cause) => + Effect.logWarning( + "T3 Connect relay is still unavailable; retrying the desired link in the background", + { cause }, ), - Schedule.upTo({ duration: "10 minutes" }), ), - }), Effect.tap((mode) => mode === null ? Effect.void @@ -1050,7 +1048,7 @@ const layerMakeServer = Layer.unwrap( ); return layerServerApplication.pipe( - Layer.provide(CloudLink.layer), + Layer.provide(CloudLink.layer.pipe(Layer.provideMerge(ConnectReachability.layer))), Layer.provideMerge(layerRuntimeServices), Layer.provideMerge( McpSessionRegistry.layer.pipe( diff --git a/apps/server/src/serverActivation.test.ts b/apps/server/src/serverActivation.test.ts deleted file mode 100644 index dc41b884ab36..000000000000 --- a/apps/server/src/serverActivation.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; - -import * as ServerActivation from "./serverActivation.ts"; - -it.effect("proves a root is parked before returning and releases it with one gate", () => - Effect.scoped( - Effect.gen(function* () { - const activation = yield* Deferred.make(); - const ran = yield* Deferred.make(); - - yield* ServerActivation.forkParked(Deferred.succeed(ran, undefined)).pipe( - Effect.provideService(ServerActivation.ServerActivation, Deferred.await(activation)), - ); - expect(yield* Deferred.isDone(ran)).toBe(false); - - yield* Deferred.succeed(activation, undefined); - yield* Deferred.await(ran); - expect(yield* Deferred.isDone(ran)).toBe(true); - }), - ), -); diff --git a/apps/server/src/serverActivation.ts b/apps/server/src/serverActivation.ts index c43cd259ef66..2945951d847a 100644 --- a/apps/server/src/serverActivation.ts +++ b/apps/server/src/serverActivation.ts @@ -9,21 +9,20 @@ export class ServerActivation extends Context.Reference | un { defaultValue: () => undefined }, ) {} -export const forkParkedFiber = ( +export const forkParkedFiber = Effect.fn("serverActivation.forkParkedFiber")(function* ( effect: Effect.Effect, -): Effect.Effect, never, Scope.Scope | R> => - Effect.gen(function* () { - const activation = yield* ServerActivation; - if (activation === undefined) { - return yield* Effect.forkScoped(effect); - } - const parked = yield* Deferred.make(); - const fiber = yield* Effect.forkScoped( - Deferred.succeed(parked, undefined).pipe(Effect.andThen(activation), Effect.andThen(effect)), - ); - yield* Deferred.await(parked); - return fiber; - }); +): Effect.fn.Return, never, Scope.Scope | R> { + const activation = yield* ServerActivation; + if (activation === undefined) { + return yield* Effect.forkScoped(effect); + } + const parked = yield* Deferred.make(); + const fiber = yield* Effect.forkScoped( + Deferred.succeed(parked, undefined).pipe(Effect.andThen(activation), Effect.andThen(effect)), + ); + yield* Deferred.await(parked); + return fiber; +}); export const forkParked = ( effect: Effect.Effect, diff --git a/apps/server/src/serverLifecycleEvents.test.ts b/apps/server/src/serverLifecycleEvents.test.ts deleted file mode 100644 index 28ba41125b57..000000000000 --- a/apps/server/src/serverLifecycleEvents.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { assert, it } from "@effect/vitest"; -import { assertTrue } from "@effect/vitest/utils"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; - -import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; - -it.effect( - "publishes lifecycle events without subscribers and snapshots the latest welcome/ready", - () => - Effect.gen(function* () { - const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; - const environment = { - environmentId: EnvironmentId.make("environment-test"), - label: "Test environment", - platform: { os: "darwin" as const, arch: "arm64" as const }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }; - - const welcome = yield* lifecycleEvents - .publish({ - version: 1, - type: "welcome", - payload: { - environment, - cwd: "/tmp/project", - projectName: "project", - }, - }) - .pipe(Effect.timeoutOption("50 millis")); - assertTrue(Option.isSome(welcome)); - assert.equal(welcome.value.sequence, 1); - - const ready = yield* lifecycleEvents - .publish({ - version: 1, - type: "ready", - payload: { - at: "2026-01-01T00:00:00.000Z", - environment, - }, - }) - .pipe(Effect.timeoutOption("50 millis")); - assertTrue(Option.isSome(ready)); - assert.equal(ready.value.sequence, 2); - - yield* lifecycleEvents.publish({ - version: 1, - type: "legacyThreadMigration", - payload: { - status: "running", - totalThreadCount: 12, - }, - }); - yield* lifecycleEvents.publish({ - version: 1, - type: "legacyThreadMigration", - payload: { - status: "complete", - totalThreadCount: 12, - }, - }); - - const snapshot = yield* lifecycleEvents.snapshot; - assert.equal(snapshot.sequence, 4); - assert.deepEqual(snapshot.events.map((event) => event.type).toSorted(), [ - "legacyThreadMigration", - "ready", - "welcome", - ]); - const migration = snapshot.events.find((event) => event.type === "legacyThreadMigration"); - assert.equal(migration?.payload.status, "complete"); - }).pipe(Effect.provide(ServerLifecycleEvents.layer)), -); diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index 70b4cd86b2cd..75c35a61b03a 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -5,7 +5,6 @@ import * as NodeUtil from "node:util"; import { ExternalLauncherBrowserSpawnError } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as ConfigProvider from "effect/ConfigProvider"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -96,24 +95,6 @@ const layerConfig = (overrides: Partial) = }), ).pipe(Layer.provide(NodePath.layer)); -/** - * The export is asserted after the layer's scope closes, which is where the - * exporter flushes whatever the interval did not. - */ -const logThrough = (overrides: Partial) => - Effect.gen(function* () { - const requests: Array = []; - yield* Effect.log("server logger under test").pipe( - Effect.provide( - ServerLogger.layer.pipe( - Layer.provide(layerConfig(overrides)), - Layer.provide(layerCollector(requests)), - ), - ), - ); - return requests; - }); - /** * Runs `program` (one log line by default) inside a span so the tracer logger * has somewhere to attach an event, then reports both what the collector @@ -171,99 +152,6 @@ const printThroughConsole = (fields: object) => }); describe("ServerLoggerLive", () => { - it.effect("exports log records to the configured logs endpoint", () => - Effect.gen(function* () { - const requests = yield* logThrough({ - otlpLogsUrl: "https://collector.example.com/v1/logs", - }); - - assert.lengthOf(requests, 1); - const [request] = requests; - assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); - assert.include(request?.body ?? "", "server logger under test"); - assert.include(request?.body ?? "", "t3code-server"); - assert.include(request?.body ?? "", "service.runtime"); - }), - ); - - it.effect("keeps its service name while OTEL resource attributes add dimensions", () => - Effect.gen(function* () { - const requests = yield* logThrough({ - otlpLogsUrl: "https://collector.example.com/v1/logs", - }).pipe( - Effect.provide( - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - OTEL_SERVICE_NAME: "renamed", - OTEL_RESOURCE_ATTRIBUTES: - "service.name=renamed,service.namespace=renamed,deployment.environment.name=development", - }, - }), - ), - ), - ); - - assert.lengthOf(requests, 1); - const body = requests[0]?.body ?? ""; - assert.include(body, '"stringValue":"t3code-server"'); - assert.include(body, "deployment.environment.name"); - assert.include(body, '"key":"service.namespace","value":{"stringValue":"t3code"}'); - assert.notInclude(body, "renamed"); - }), - ); - - it.effect("stays off the network when no logs endpoint is configured", () => - Effect.gen(function* () { - const requests = yield* logThrough({}); - - assert.lengthOf(requests, 0); - }), - ); - - it.effect("sends the headers and wire format the log signal asked for", () => - Effect.gen(function* () { - const requests = yield* logThrough({ - otlpLogsUrl: "https://collector.example.com/v1/logs", - otlpLogsExport: { - ...DEFAULT_SIGNAL_EXPORT, - protocol: "http/protobuf", - headers: { "x-scope": "logs" }, - }, - }); - - assert.lengthOf(requests, 1); - assert.strictEqual(requests[0]?.headers["x-scope"], "logs"); - assert.strictEqual(requests[0]?.headers["content-type"], "application/x-protobuf"); - }), - ); - - it.effect("attaches log messages to the active span when no logs endpoint is configured", () => - Effect.gen(function* () { - const { requests, spans } = yield* logInSpanThrough({}); - - assert.lengthOf(requests, 0); - assert.lengthOf(spans, 1); - assert.deepEqual( - spans[0]?.events.map(([name]) => name), - ["server logger under test"], - ); - }), - ); - - it.effect("stops duplicating messages onto the span once log records are exported", () => - Effect.gen(function* () { - const { requests, spans } = yield* logInSpanThrough({ - otlpLogsUrl: "https://collector.example.com/v1/logs", - }); - - assert.lengthOf(requests, 1); - assert.include(requests[0]?.body ?? "", "server logger under test"); - assert.lengthOf(spans, 1); - assert.lengthOf(spans[0]?.events ?? [], 0); - }), - ); - it.effect("prints a cause passed as a log field instead of collapsing it", () => Effect.gen(function* () { const output = yield* printThroughConsole({ @@ -277,6 +165,7 @@ describe("ServerLoggerLive", () => { }), ); + // regression: #16642 logs printed causes as [Object] it.effect("leaves getters uncalled while printing a cause field", () => Effect.gen(function* () { const output = yield* printThroughConsole({ diff --git a/apps/server/src/serverOwnership.ts b/apps/server/src/serverOwnership.ts index 4c4811de09e5..50d0b2332c22 100644 --- a/apps/server/src/serverOwnership.ts +++ b/apps/server/src/serverOwnership.ts @@ -8,10 +8,25 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Runtime from "effect/Runtime"; import * as Schema from "effect/Schema"; +import { + BOOT_SERVICE_LAUNCHD_LABEL, + BOOT_SERVICE_PLIST_FILE, + BOOT_SERVICE_UNIT_FILE, + bootServiceBaseDirOf, +} from "./cloud/bootService.ts"; +import { ServiceLauncherHostProcess } from "./cloud/serviceLauncherClient.ts"; +import { + SERVICE_LAUNCHER_CONTEXT_ENV, + SERVICE_RESTART_PENDING_FILE, + SERVICE_STATE_FILE, + compareExactServiceVersions, + serviceStateActiveVersion, +} from "./cloud/serviceProtocol.ts"; import * as ProcessRunner from "./processRunner.ts"; import { clearPersistedServerRuntimeState, @@ -34,6 +49,17 @@ export class ServerAlreadyRunningError extends Schema.TaggedError()( + "OlderBootServiceError", + { baseDir: Schema.String }, +) { + override readonly [Runtime.errorExitCode] = SERVER_EXIT_CODE_STATE_DIR_OWNED; + + override get message(): string { + return `A T3 Code background service set up for ${this.baseDir} may still run a release without the ownership lock. On that machine, try \`t3 update\` and let it restart (or run \`t3 service restart\` if the update is already installed). If the older launcher cannot update, run \`t3 service uninstall\`, then reinstall with \`t3 service install\` from a current CLI. You can also leave the service uninstalled. Pass \`--base-dir ${this.baseDir}\` to each command, and use \`npx t3@latest\` if \`t3\` is not installed or is outdated. Then retry. No server was stopped.`; + } +} + export class ServerOwnershipError extends Schema.TaggedError()( "ServerOwnershipError", { stateDir: Schema.String, cause: Schema.Defect() }, @@ -83,6 +109,75 @@ const legacyOwnerIsLive = Effect.fn("legacyOwnerIsLive")(function* ( return !Number.isFinite(startedAt) || startedAt <= recordedAt + 1_000; }); +// Pre-lock services become invisible when another server replaces their runtime +// record. Unreadable configuration or an unavailable manager allows startup. +const olderBootServiceHome = Effect.fn("olderBootServiceHome")( + function* (stateDir: string) { + if ( + (yield* ServiceLauncherHostProcess).connected && + (yield* HostProcess.Environment)[SERVICE_LAUNCHER_CONTEXT_ENV] !== undefined + ) { + return undefined; + } + const platform = yield* HostProcess.Platform; + const uid = yield* HostProcess.UserId; + if (platform !== "linux" && (platform !== "darwin" || uid === undefined)) return undefined; + + const homeDir = yield* HostProcess.HomeDirectory; + const fs = yield* FileSystem.FileSystem; + const runner = yield* ProcessRunner.ProcessRunner; + const unitPath = + platform === "darwin" + ? NodePath.join(homeDir, "Library", "LaunchAgents", BOOT_SERVICE_PLIST_FILE) + : NodePath.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE); + const baseDir = bootServiceBaseDirOf(yield* fs.readFileString(unitPath)); + if ( + baseDir === undefined || + (yield* fs.realPath(NodePath.join(baseDir, "userdata"))) !== (yield* fs.realPath(stateDir)) + ) { + return undefined; + } + const runtimeDir = NodePath.join(baseDir, "runtime"); + const version = serviceStateActiveVersion( + yield* fs.readFileString(NodePath.join(runtimeDir, SERVICE_STATE_FILE)), + ); + if (version === undefined) return undefined; + const run = (command: string, args: ReadonlyArray) => + runner.run({ command, args, timeout: Duration.seconds(5), maxOutputBytes: 65_536 }); + let loaded: boolean; + if (platform === "darwin") { + loaded = + (yield* run("launchctl", ["print", `gui/${uid}/${BOOT_SERVICE_LAUNCHD_LABEL}`])).code === 0; + if (!loaded) { + const disabled = yield* run("launchctl", ["print-disabled", `gui/${uid}`]); + // Older macOS versions print `true` instead of `disabled`. + if ( + disabled.code !== 0 || + disabled.stdout.includes(`"${BOOT_SERVICE_LAUNCHD_LABEL}" => disabled`) || + disabled.stdout.includes(`"${BOOT_SERVICE_LAUNCHD_LABEL}" => true`) + ) + return undefined; + } + } else { + const active = yield* run("systemctl", ["--user", "is-active", BOOT_SERVICE_UNIT_FILE]); + // A unit waiting out its Restart= delay is activating, with exit code 3. + loaded = active.code === 0 || (active.code === 3 && active.stdout.trim() === "activating"); + if (!loaded) { + const enabled = yield* run("systemctl", ["--user", "is-enabled", BOOT_SERVICE_UNIT_FILE]); + if (enabled.code !== 0 || enabled.stdout.trim() !== "enabled") return undefined; + } + } + // A deferred update names the staged runtime, not the loaded service's runtime. + if (loaded && (yield* fs.exists(NodePath.join(runtimeDir, SERVICE_RESTART_PENDING_FILE)))) { + return baseDir; + } + // Stable 0.1.0 shipped the ownership lock. Earlier lock-aware prereleases + // are conservatively refused because older runtimes expose no capability marker. + return compareExactServiceVersions(version, "0.1.0") < 0 ? baseDir : undefined; + }, + Effect.orElseSucceed(() => undefined), +); + /** * Hold an exclusive lock on the state directory until the scope closes. The * lock is an exclusive transaction on `server-owner.sqlite`, which never holds @@ -137,6 +232,10 @@ export const acquireServerOwnership = Effect.fn("acquireServerOwnership")(functi ) { return yield* new ServerAlreadyRunningError({ stateDir }); } + const olderServiceHome = yield* olderBootServiceHome(stateDir); + if (olderServiceHome !== undefined) { + return yield* new OlderBootServiceError({ baseDir: olderServiceHome }); + } return { publish: (state: PersistedServerRuntimeState) => diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index a919f6720fbf..59f6b67f0450 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { HttpServer } from "effect/http"; @@ -30,6 +31,7 @@ import { ServerActivation } from "./serverActivation.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; +import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -163,6 +165,9 @@ it.effect("parks automatic pull until activation without delaying command readin ), }), Layer.mock(AnalyticsService.AnalyticsService)({ record: () => Effect.void }), + // No provider logins or Connect link, so startup records no owners. + FileSystem.layerNoop({}), + Layer.mock(ServerSecretStore.ServerSecretStore)({ get: () => Effect.succeedNone }), NodeCrypto.layer, Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), Layer.mock(ExternalLauncher.ExternalLauncher)({}), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 3d0f2ba1d8c2..71ce7d9cb9a8 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -2,7 +2,6 @@ import { assert, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { DEFAULT_SERVER_SETTINGS, - DEFAULT_MODEL, ProjectId, ProviderInstanceId, ProviderDriverKind, @@ -16,6 +15,7 @@ import { HttpServer } from "effect/http"; import * as NetAddress from "effect/net/NetAddress"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Exit from "effect/Exit"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Fiber from "effect/Fiber"; @@ -45,15 +45,9 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { ServerActivation } from "./serverActivation.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerSettings from "./serverSettings.ts"; +import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; -it("uses the canonical Codex model for auto-bootstrap", () => { - assert.deepEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }); -}); - it.effect.each([ "disabled-codex", "disabled-default", @@ -217,6 +211,9 @@ it.effect.each([ }), Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(AnalyticsService.AnalyticsService)({}), + // No provider logins or Connect link, so startup records no owners. + FileSystem.layerNoop({}), + Layer.mock(ServerSecretStore.ServerSecretStore)({ get: () => Effect.succeedNone }), Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), Layer.mock(ExternalLauncher.ExternalLauncher)({}), Layer.mock(HttpServer.HttpServer)({ @@ -246,33 +243,6 @@ it.effect.each([ ), ); -it.effect("starts without scanning or rebuilding projection history", () => - Effect.gen(function* () { - const calls = yield* Ref.make>([]); - const record = (label: string) => Ref.update(calls, (current) => [...current, label]); - - const result = yield* ServerRuntimeStartup.runOrderedV2StartupPhases({ - importLegacyShells: record("import"), - recover: record("recover").pipe(Effect.as({ closedRequests: 2 })), - recoverDelegatedTasks: record("delegated"), - startEffectWorker: record("worker"), - autoBootstrap: record("bootstrap").pipe(Effect.as({ projectId: "project-1" })), - }); - - assert.deepEqual(yield* Ref.get(calls), [ - "import", - "recover", - "delegated", - "worker", - "bootstrap", - ]); - assert.deepEqual(result, { - recovery: { closedRequests: 2 }, - bootstrap: { projectId: "project-1" }, - }); - }), -); - it.effect("interrupts the effect worker when awareness relay startup fails", () => Effect.scoped( Effect.gen(function* () { @@ -336,21 +306,6 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => ), ); -it.effect("resolveWelcomeBase derives cwd and project name from server config", () => - Effect.gen(function* () { - const welcome = yield* ServerRuntimeStartup.resolveWelcomeBase.pipe( - Effect.provideService(ServerConfig.ServerConfig, { - cwd: "/tmp/startup-project", - } as never), - ); - - assert.deepStrictEqual(welcome, { - cwd: "/tmp/startup-project", - projectName: "startup-project", - }); - }), -); - it.effect("opens the startup pairing link on the public URL of a proxied loopback server", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 233db144b1db..0b00654e344a 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -45,6 +45,7 @@ import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerSettings from "./serverSettings.ts"; import { forkParked, forkParkedFiber } from "./serverActivation.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; +import { getServerOwners } from "./telemetry/Identify.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { @@ -109,37 +110,39 @@ export const makeCommandGate = Effect.gen(function* () { ); yield* Effect.forkScoped(commandWorker); + const enqueueCommand = Effect.fn("serverRuntimeStartup.enqueueCommand")(function* ( + effect: Effect.Effect, + ): Effect.fn.Return { + const readinessState = yield* Ref.get(commandReadinessState); + if (readinessState === "ready") { + return yield* effect; + } + if (readinessState !== "pending") { + return yield* readinessState; + } + + const result = yield* Deferred.make(); + yield* Queue.offer(commandQueue, { + run: Deferred.await(commandReady).pipe( + Effect.flatMap(() => effect), + Effect.exit, + Effect.flatMap((exit) => settleQueuedCommand(result, exit)), + ), + }); + return yield* Deferred.await(result); + }); + return { awaitCommandReady: Deferred.await(commandReady), signalCommandReady: Effect.gen(function* () { yield* Ref.set(commandReadinessState, "ready"); yield* Deferred.succeed(commandReady, undefined).pipe(Effect.orDie); }), - failCommandReady: (error) => - Effect.gen(function* () { - yield* Ref.set(commandReadinessState, error); - yield* Deferred.fail(commandReady, error).pipe(Effect.orDie); - }), - enqueueCommand: (effect: Effect.Effect) => - Effect.gen(function* () { - const readinessState = yield* Ref.get(commandReadinessState); - if (readinessState === "ready") { - return yield* effect; - } - if (readinessState !== "pending") { - return yield* readinessState; - } - - const result = yield* Deferred.make(); - yield* Queue.offer(commandQueue, { - run: Deferred.await(commandReady).pipe( - Effect.flatMap(() => effect), - Effect.exit, - Effect.flatMap((exit) => settleQueuedCommand(result, exit)), - ), - }); - return yield* Deferred.await(result); - }), + failCommandReady: Effect.fn("serverRuntimeStartup.failCommandReady")(function* (error) { + yield* Ref.set(commandReadinessState, error); + yield* Deferred.fail(commandReady, error).pipe(Effect.orDie); + }), + enqueueCommand, } satisfies CommandGate; }); @@ -172,9 +175,10 @@ const recordStartupHeartbeat = Effect.gen(function* () { threadCount, projectCount, }); + yield* analytics.recordOwners(yield* getServerOwners, "server"); }); -export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ +const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }); @@ -244,7 +248,7 @@ export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( ); }); -export const resolveWelcomeBase = Effect.gen(function* () { +const resolveWelcomeBase = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean); const projectName = segments[segments.length - 1] ?? "project"; @@ -358,21 +362,22 @@ export const logBrowserAutoOpenUnavailable = Effect.logInfo("browser auto-open u hint: "Open the pairing URL above in your browser.", }); -export const maybeOpenBrowser = (target: string) => - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - if (serverConfig.noBrowser) { - return; - } - const externalLauncher = yield* ExternalLauncher.ExternalLauncher; +export const maybeOpenBrowser = Effect.fn("serverRuntimeStartup.maybeOpenBrowser")(function* ( + target: string, +) { + const serverConfig = yield* ServerConfig.ServerConfig; + if (serverConfig.noBrowser) { + return; + } + const externalLauncher = yield* ExternalLauncher.ExternalLauncher; - yield* externalLauncher.launchBrowser(target).pipe( - // The target carries the pairing token, and a failed launch records its - // command line on the span, so this call stays out of traces. - Effect.withTracerEnabled(false), - Effect.catch(() => logBrowserAutoOpenUnavailable), - ); - }); + yield* externalLauncher.launchBrowser(target).pipe( + // The target carries the pairing token, and a failed launch records its + // command line on the span, so this call stays out of traces. + Effect.withTracerEnabled(false), + Effect.catch(() => logBrowserAutoOpenUnavailable), + ); +}); const runStartupPhase = (phase: string, effect: Effect.Effect) => effect.pipe( @@ -413,313 +418,309 @@ export const startEffectWorkerWithRelay = Effect.fn( ); }); -export function runOrderedV2StartupPhases< - Import, - Recovery, - Bootstrap, - ImportError, - RecoveryError, - DelegationError, - WorkerError, - BootstrapError, - ImportContext, - RecoveryContext, - DelegationContext, - WorkerContext, - BootstrapContext, ->(input: { - readonly importLegacyShells: Effect.Effect; - readonly recover: Effect.Effect; - readonly recoverDelegatedTasks: Effect.Effect; - readonly startEffectWorker: Effect.Effect; - readonly autoBootstrap: Effect.Effect; -}) { - return Effect.gen(function* () { +const runOrderedV2StartupPhases = Effect.fn("serverRuntimeStartup.runOrderedV2StartupPhases")( + function* < + Import, + Recovery, + Bootstrap, + ImportError, + RecoveryError, + DelegationError, + WorkerError, + BootstrapError, + ImportContext, + RecoveryContext, + DelegationContext, + WorkerContext, + BootstrapContext, + >(input: { + readonly importLegacyShells: Effect.Effect; + readonly recover: Effect.Effect; + readonly recoverDelegatedTasks: Effect.Effect; + readonly startEffectWorker: Effect.Effect; + readonly autoBootstrap: Effect.Effect; + }) { yield* input.importLegacyShells; const recovery = yield* input.recover; yield* input.recoverDelegatedTasks; yield* input.startEffectWorker; const bootstrap = yield* input.autoBootstrap; return { recovery, bootstrap } as const; - }); -} + }, +); -const make = (options?: StartupOptions) => - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const keybindings = yield* Keybindings.Keybindings; - const legacyV1ThreadImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; - const providerRuntimeRecovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; - const orchestrator = yield* Orchestrator.OrchestratorV2; - const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; - const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; - const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; - const serverSettings = yield* ServerSettings.ServerSettingsService; - const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; - const crypto = yield* Crypto.Crypto; - const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; - - const commandGate = yield* makeCommandGate; - const httpListening = yield* Deferred.make(); - const effectWorkerFiber = yield* Ref.make | null>(null); - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - yield* commandGate.failCommandReady( - new ServerRuntimeStartupError({ - mode: serverConfig.mode, - host: serverConfig.host ?? null, - port: serverConfig.port, - cause: "Server runtime is shutting down.", - }), - ); - const workerFiber = yield* Ref.getAndSet(effectWorkerFiber, null); - if (workerFiber !== null) { - yield* Fiber.interrupt(workerFiber).pipe(Effect.ignore); - } - yield* providerRuntimeRecovery.prepareForShutdown.pipe( - Effect.ensuring(providerSessions.shutdown), - ); - const reconciliation = yield* providerRuntimeRecovery.reconcile("shutdown"); - yield* Effect.logInfo("V2 orchestration shutdown reconciliation completed", reconciliation); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("V2 orchestration shutdown reconciliation failed", { - cause: Cause.pretty(cause), +const make = Effect.fn("serverRuntimeStartup.make")(function* (options?: StartupOptions) { + const serverConfig = yield* ServerConfig.ServerConfig; + const keybindings = yield* Keybindings.Keybindings; + const legacyV1ThreadImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; + const providerRuntimeRecovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; + const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const crypto = yield* Crypto.Crypto; + const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; + + const commandGate = yield* makeCommandGate; + const httpListening = yield* Deferred.make(); + const effectWorkerFiber = yield* Ref.make | null>(null); + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* commandGate.failCommandReady( + new ServerRuntimeStartupError({ + mode: serverConfig.mode, + host: serverConfig.host ?? null, + port: serverConfig.port, + cause: "Server runtime is shutting down.", + }), + ); + const workerFiber = yield* Ref.getAndSet(effectWorkerFiber, null); + if (workerFiber !== null) { + yield* Fiber.interrupt(workerFiber).pipe(Effect.ignore); + } + yield* providerRuntimeRecovery.prepareForShutdown.pipe( + Effect.ensuring(providerSessions.shutdown), + ); + const reconciliation = yield* providerRuntimeRecovery.reconcile("shutdown"); + yield* Effect.logInfo("V2 orchestration shutdown reconciliation completed", reconciliation); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("V2 orchestration shutdown reconciliation failed", { + cause: Cause.pretty(cause), + }), + ), + ), + ); + + const startup = Effect.gen(function* () { + yield* Effect.logDebug("startup phase: starting keybindings runtime"); + yield* runStartupPhase( + "keybindings.start", + keybindings.start.pipe( + Effect.catch((error) => + Effect.logWarning("failed to start keybindings runtime", { + path: error.configPath, + detail: error.detail, + cause: error.cause, }), ), ), ); - const startup = Effect.gen(function* () { - yield* Effect.logDebug("startup phase: starting keybindings runtime"); - yield* runStartupPhase( - "keybindings.start", - keybindings.start.pipe( - Effect.catch((error) => - Effect.logWarning("failed to start keybindings runtime", { - path: error.configPath, - detail: error.detail, - cause: error.cause, - }), - ), + yield* Effect.logDebug("startup phase: starting server settings runtime"); + yield* runStartupPhase( + "settings.start", + serverSettings.start.pipe( + Effect.catch((error) => + Effect.logWarning("failed to start server settings runtime", { + path: error.settingsPath, + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, + cause: error.cause, + }), ), - ); + ), + ); - yield* Effect.logDebug("startup phase: starting server settings runtime"); - yield* runStartupPhase( - "settings.start", - serverSettings.start.pipe( - Effect.catch((error) => - Effect.logWarning("failed to start server settings runtime", { - path: error.settingsPath, - operation: error.operation, - providerInstanceId: error.providerInstanceId, - environmentVariable: error.environmentVariable, - cause: error.cause, - }), + const welcomeBase = yield* resolveWelcomeBase; + const environment = yield* serverEnvironment.getDescriptor; + const legacyMigrationThreadCount = yield* legacyV1ThreadImporter.pendingThreadCount; + if (legacyMigrationThreadCount > 0) { + yield* lifecycleEvents.publish({ + version: 1, + type: "legacyThreadMigration", + payload: { + status: "running", + totalThreadCount: legacyMigrationThreadCount, + }, + }); + } + const { recovery, bootstrap: bootstrapTargets } = yield* runOrderedV2StartupPhases({ + importLegacyShells: runStartupPhase( + "orchestration-v2.legacy-v1.import-shells", + legacyV1ThreadImporter.reconcileShells.pipe( + Effect.tap((summary) => + summary.importedThreadCount === 0 + ? Effect.void + : Effect.logInfo("Imported legacy v1 thread shells", summary), ), ), - ); + ), + recover: runStartupPhase("orchestration-v2.recovery", providerRuntimeRecovery.recover), + recoverDelegatedTasks: runStartupPhase( + "orchestration-v2.delegated-tasks.recover", + orchestrator.recoverDelegatedTasks, + ), + startEffectWorker: runStartupPhase( + "orchestration-v2.effect-worker.start", + startEffectWorkerWithRelay({ + runWorker: EffectWorker.runDaemon, + startRelay: agentAwarenessRelay.start(), + workerFiberRef: effectWorkerFiber, + }), + ), + autoBootstrap: (serverConfig.autoBootstrapProjectFromCwd + ? runStartupPhase( + "welcome.autobootstrap", + resolveAutoBootstrapWelcomeTargets.pipe(Effect.provideService(Crypto.Crypto, crypto)), + ) + : Effect.succeed({}) + ).pipe(Effect.map((targets): AutoBootstrapWelcomeTargets => targets)), + }); + yield* Effect.logInfo("V2 orchestration recovery completed", recovery); + // Runs after activation: the status check fetches every enabled project's + // remote, and awaiting it here held command readiness for that long. + yield* runStartupPhase( + "projects.auto-pull", + Effect.gen(function* () { + const projects = yield* (yield* ProjectStore.ProjectStoreV2).listShells(); + const settings = yield* serverSettings.getSettings; + yield* autoPullProjects(projects, settings); + }), + ).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to load projects for automatic pull", { cause }), + ), + forkParked, + ); - const welcomeBase = yield* resolveWelcomeBase; - const environment = yield* serverEnvironment.getDescriptor; - const legacyMigrationThreadCount = yield* legacyV1ThreadImporter.pendingThreadCount; - if (legacyMigrationThreadCount > 0) { - yield* lifecycleEvents.publish({ - version: 1, - type: "legacyThreadMigration", - payload: { - status: "running", - totalThreadCount: legacyMigrationThreadCount, - }, - }); - } - const { recovery, bootstrap: bootstrapTargets } = yield* runOrderedV2StartupPhases({ - importLegacyShells: runStartupPhase( - "orchestration-v2.legacy-v1.import-shells", - legacyV1ThreadImporter.reconcileShells.pipe( - Effect.tap((summary) => - summary.importedThreadCount === 0 - ? Effect.void - : Effect.logInfo("Imported legacy v1 thread shells", summary), + const importPendingTranscripts = legacyV1ThreadImporter.importPendingTranscripts.pipe( + Effect.tap((summary) => + summary.importedThreadCount === 0 + ? Effect.void + : Effect.logInfo("Hydrated legacy v1 thread transcripts", summary), + ), + ); + yield* ( + legacyMigrationThreadCount > 0 + ? importPendingTranscripts.pipe( + Effect.tap(() => + lifecycleEvents.publish({ + version: 1, + type: "legacyThreadMigration", + payload: { + status: "complete", + totalThreadCount: legacyMigrationThreadCount, + }, + }), ), - ), - ), - recover: runStartupPhase("orchestration-v2.recovery", providerRuntimeRecovery.recover), - recoverDelegatedTasks: runStartupPhase( - "orchestration-v2.delegated-tasks.recover", - orchestrator.recoverDelegatedTasks, - ), - startEffectWorker: runStartupPhase( - "orchestration-v2.effect-worker.start", - startEffectWorkerWithRelay({ - runWorker: EffectWorker.runDaemon, - startRelay: agentAwarenessRelay.start(), - workerFiberRef: effectWorkerFiber, - }), - ), - autoBootstrap: (serverConfig.autoBootstrapProjectFromCwd - ? runStartupPhase( - "welcome.autobootstrap", - resolveAutoBootstrapWelcomeTargets.pipe(Effect.provideService(Crypto.Crypto, crypto)), - ) - : Effect.succeed({}) - ).pipe(Effect.map((targets): AutoBootstrapWelcomeTargets => targets)), - }); - yield* Effect.logInfo("V2 orchestration recovery completed", recovery); - // Runs after activation: the status check fetches every enabled project's - // remote, and awaiting it here held command readiness for that long. - yield* runStartupPhase( - "projects.auto-pull", - Effect.gen(function* () { - const projects = yield* (yield* ProjectStore.ProjectStoreV2).listShells(); - const settings = yield* serverSettings.getSettings; - yield* autoPullProjects(projects, settings); - }), - ).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to load projects for automatic pull", { cause }), - ), - forkParked, - ); + ) + : importPendingTranscripts + ).pipe(forkParked); - const importPendingTranscripts = legacyV1ThreadImporter.importPendingTranscripts.pipe( - Effect.tap((summary) => - summary.importedThreadCount === 0 - ? Effect.void - : Effect.logInfo("Hydrated legacy v1 thread transcripts", summary), - ), - ); - yield* ( - legacyMigrationThreadCount > 0 - ? importPendingTranscripts.pipe( - Effect.tap(() => - lifecycleEvents.publish({ - version: 1, - type: "legacyThreadMigration", - payload: { - status: "complete", - totalThreadCount: legacyMigrationThreadCount, - }, - }), - ), - ) - : importPendingTranscripts - ).pipe(forkParked); - - yield* forkParked( - Effect.gen(function* () { - yield* Effect.logDebug("startup phase: recording startup heartbeat"); - yield* recordStartupHeartbeat.pipe( - Effect.withSpan("server.startup.heartbeat.record", { - attributes: { "startup.phase": "heartbeat.record" }, - }), - Effect.ignoreCause({ log: true }), + yield* forkParked( + Effect.gen(function* () { + yield* Effect.logDebug("startup phase: recording startup heartbeat"); + yield* recordStartupHeartbeat.pipe( + Effect.withSpan("server.startup.heartbeat.record", { + attributes: { "startup.phase": "heartbeat.record" }, + }), + Effect.ignoreCause({ log: true }), + ); + if (serverConfig.startupPresentation === "headless") { + yield* Effect.logDebug("startup phase: headless access info"); + const accessInfo = yield* issueHeadlessServeAccessInfo(); + yield* runStartupPhase( + "headless.output", + Console.log(formatHeadlessServeOutput(accessInfo)), ); - if (serverConfig.startupPresentation === "headless") { - yield* Effect.logDebug("startup phase: headless access info"); - const accessInfo = yield* issueHeadlessServeAccessInfo(); - yield* runStartupPhase( - "headless.output", - Console.log(formatHeadlessServeOutput(accessInfo)), - ); - } else { - yield* Effect.logDebug("startup phase: browser open check"); - const startupBrowserTarget = yield* resolveStartupBrowserTarget; - if (serverConfig.mode !== "desktop") { - yield* announceStartupPairingUrl(startupBrowserTarget); - } - yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); + } else { + yield* Effect.logDebug("startup phase: browser open check"); + const startupBrowserTarget = yield* resolveStartupBrowserTarget; + if (serverConfig.mode !== "desktop") { + yield* announceStartupPairingUrl(startupBrowserTarget); } - }), - ); - - yield* Effect.logDebug("startup phase: waiting for http listener"); - yield* runStartupPhase("http.wait", Deferred.await(httpListening)); - yield* runStartupPhase( - "auxiliary-roots.parked", - options?.awaitAuxiliaryParked ?? Effect.void, - ); + yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); + } + }), + ); - const updateOutcome = yield* launcher.prepareTrial; + yield* Effect.logDebug("startup phase: waiting for http listener"); + yield* runStartupPhase("http.wait", Deferred.await(httpListening)); + yield* runStartupPhase("auxiliary-roots.parked", options?.awaitAuxiliaryParked ?? Effect.void); - yield* Effect.logDebug("startup phase: publishing welcome event", { - environmentId: environment.environmentId, - cwd: welcomeBase.cwd, - projectName: welcomeBase.projectName, - bootstrapProjectId: bootstrapTargets.bootstrapProjectId, - bootstrapThreadId: bootstrapTargets.bootstrapThreadId, - }); - yield* runStartupPhase( - "welcome.publish", - lifecycleEvents.publish({ - version: 1, - type: "welcome", - payload: { - environment, - ...welcomeBase, - ...bootstrapTargets, - }, - }), - ); + const updateOutcome = yield* launcher.prepareTrial; - yield* options?.activate ?? Effect.void; - yield* Effect.logDebug("Accepting commands"); - yield* commandGate.signalCommandReady; - yield* Effect.logDebug("startup phase: publishing ready event"); - yield* runStartupPhase( - "ready.publish", - lifecycleEvents.publish({ - version: 1, - type: "ready", - payload: { - at: DateTime.formatIso(yield* DateTime.now), - environment, - ...(updateOutcome === undefined ? {} : { updateOutcome }), - }, - }), - ); - yield* Effect.logDebug("startup phase: complete"); - yield* flushCompileCache; - }).pipe( - Effect.withSpan("server.startup", { - kind: "server", - root: true, - attributes: { - "server.mode": serverConfig.mode, - "server.port": serverConfig.port, - "server.host": serverConfig.host ?? "default", + yield* Effect.logDebug("startup phase: publishing welcome event", { + environmentId: environment.environmentId, + cwd: welcomeBase.cwd, + projectName: welcomeBase.projectName, + bootstrapProjectId: bootstrapTargets.bootstrapProjectId, + bootstrapThreadId: bootstrapTargets.bootstrapThreadId, + }); + yield* runStartupPhase( + "welcome.publish", + lifecycleEvents.publish({ + version: 1, + type: "welcome", + payload: { + environment, + ...welcomeBase, + ...bootstrapTargets, }, }), ); - yield* Effect.forkScoped( - Effect.exit(startup).pipe( - Effect.flatMap((startupExit) => { - if (Exit.isSuccess(startupExit)) return Effect.void; - const error = new ServerRuntimeStartupError({ - mode: serverConfig.mode, - host: serverConfig.host ?? null, - port: serverConfig.port, - cause: startupExit.cause, - }); - return Effect.logError("server runtime startup failed", { - cause: Cause.pretty(startupExit.cause), - }).pipe( - Effect.andThen(commandGate.failCommandReady(error)), - Effect.andThen(options?.abort?.(error) ?? Effect.void), - ); - }), - ), + yield* options?.activate ?? Effect.void; + yield* Effect.logDebug("Accepting commands"); + yield* commandGate.signalCommandReady; + yield* Effect.logDebug("startup phase: publishing ready event"); + yield* runStartupPhase( + "ready.publish", + lifecycleEvents.publish({ + version: 1, + type: "ready", + payload: { + at: DateTime.formatIso(yield* DateTime.now), + environment, + ...(updateOutcome === undefined ? {} : { updateOutcome }), + }, + }), ); + yield* Effect.logDebug("startup phase: complete"); + yield* flushCompileCache; + }).pipe( + Effect.withSpan("server.startup", { + kind: "server", + root: true, + attributes: { + "server.mode": serverConfig.mode, + "server.port": serverConfig.port, + "server.host": serverConfig.host ?? "default", + }, + }), + ); - return { - awaitCommandReady: commandGate.awaitCommandReady, - markHttpListening: Deferred.succeed(httpListening, undefined), - enqueueCommand: commandGate.enqueueCommand, - } satisfies ServerRuntimeStartup["Service"]; - }); + yield* Effect.forkScoped( + Effect.exit(startup).pipe( + Effect.flatMap((startupExit) => { + if (Exit.isSuccess(startupExit)) return Effect.void; + const error = new ServerRuntimeStartupError({ + mode: serverConfig.mode, + host: serverConfig.host ?? null, + port: serverConfig.port, + cause: startupExit.cause, + }); + return Effect.logError("server runtime startup failed", { + cause: Cause.pretty(startupExit.cause), + }).pipe( + Effect.andThen(commandGate.failCommandReady(error)), + Effect.andThen(options?.abort?.(error) ?? Effect.void), + ); + }), + ), + ); + + return { + awaitCommandReady: commandGate.awaitCommandReady, + markHttpListening: Deferred.succeed(httpListening, undefined), + enqueueCommand: commandGate.enqueueCommand, + } satisfies ServerRuntimeStartup["Service"]; +}); export const layerWithOptions = (options?: StartupOptions) => Layer.effect(ServerRuntimeStartup, make(options)); diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts index b925b03d27d0..abafbc7e24f6 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -1,27 +1,144 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as References from "effect/References"; -import * as Schema from "effect/Schema"; +import * as Runtime from "effect/Runtime"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { renderBootServicePlist, renderBootServiceUnit } from "./cloud/bootService.ts"; +import { ServiceLauncherHostProcess } from "./cloud/serviceLauncherClient.ts"; +import { SERVICE_LAUNCHER_CONTEXT_ENV } from "./cloud/serviceProtocol.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as ServerOwnership from "./serverOwnership.ts"; import * as ServerRuntimeState from "./serverRuntimeState.ts"; -const isServerRuntimeStateError = Schema.is(ServerRuntimeState.ServerRuntimeStateError); +const startBesideBootService = Effect.fn("test.startBesideBootService")(function* (input: { + readonly platform: "darwin" | "linux"; + readonly outputs: Readonly>; + readonly unitServesOtherHome?: boolean; + readonly restartPending?: boolean; + readonly activeVersion?: string; + readonly npmRuntime?: boolean; + readonly missingServiceState?: boolean; + readonly environment?: NodeJS.ProcessEnv; + readonly connected?: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-owner-boot-service-" }); + const t3Home = path.join(root, ".t3"); + const serviceHome = input.unitServesOtherHome ? path.join(root, "other") : t3Home; + for (const home of [t3Home, serviceHome]) { + yield* fs.makeDirectory(path.join(home, "userdata"), { recursive: true }); + } + yield* fs.makeDirectory(path.join(serviceHome, "runtime")); + const activeVersion = input.activeVersion ?? "0.0.42"; + if (!input.missingServiceState) { + yield* fs.writeFileString( + path.join(serviceHome, "runtime", "service-state.json"), + JSON.stringify({ protocol: input.npmRuntime ? 2 : 3, activeVersion }), + ); + } + if (input.npmRuntime) { + const versionDir = path.join(serviceHome, "runtime", "versions", activeVersion); + const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); + yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); + yield* fs.writeFileString(entryPath, ""); + } + + if (input.restartPending) { + yield* fs.writeFileString( + path.join(serviceHome, "runtime", ".restart-pending"), + `${activeVersion}\n`, + ); + } + const plan = { + program: input.npmRuntime + ? ["/usr/bin/node", path.join(serviceHome, "runtime", "service-launcher.mjs")] + : [], + baseDir: serviceHome, + logPath: "", + unitPath: "", + }; + const [unitPath, unit] = + input.platform === "darwin" + ? [ + path.join(root, "Library", "LaunchAgents", "com.t3tools.t3code.service.plist"), + renderBootServicePlist(plan, { homeDir: root, environmentPath: "" }), + ] + : [ + path.join(root, ".config", "systemd", "user", "t3code.service"), + renderBootServiceUnit(plan), + ]; + yield* fs.makeDirectory(path.dirname(unitPath), { recursive: true }); + yield* fs.writeFileString(unitPath, unit); -interface CapturedLog { - readonly message: unknown; - readonly annotations: Readonly>; -} + const probes: string[] = []; + const runner = ProcessRunner.ProcessRunner.of({ + run: (run) => + Effect.gen(function* () { + const probe = `${path.basename(run.command)} ${run.args.find((arg) => !arg.startsWith("--"))}`; + probes.push(probe); + const output = input.outputs[probe]; + return yield* output === undefined + ? Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: run.command, + argumentCount: run.args.length, + cause: new Error("not found"), + }), + ) + : Effect.succeed({ + stdout: output.stdout ?? "", + stderr: "", + code: ChildProcessSpawner.ExitCode(output.code), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }); + }), + }); + const result = yield* Effect.scoped( + ServerOwnership.acquireServerOwnership(path.join(t3Home, "userdata", "server-runtime.json")), + ).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.provideService(HostProcess.Platform, input.platform), + Effect.provideService(HostProcess.UserId, 501), + Effect.provideService(HostProcess.HomeDirectory, root), + Effect.provideService(HostProcess.Environment, input.environment ?? {}), + Effect.provideService(ServiceLauncherHostProcess, { + connected: input.connected ?? false, + send: () => false, + on: () => undefined, + off: () => undefined, + }), + Effect.result, + ); + assert.includeMembers( + ["launchctl print", "launchctl print-disabled", "systemctl is-active", "systemctl is-enabled"], + probes, + ); + return { result, t3Home }; +}); describe("server ownership", () => { - const layerOwnership = ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer)); + // An empty home, so no boot service unit installed on this machine is read. + const layerOwnership = Layer.mergeAll( + ProcessRunner.layer, + Layer.effect( + HostProcess.HomeDirectory, + Effect.gen(function* () { + return yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped(); + }), + ), + Layer.succeed(HostProcess.Environment, {}), + ).pipe(Layer.provideMerge(NodeServices.layer)); const makeState = ServerRuntimeState.makePersistedServerRuntimeState({ config: { host: undefined, devUrl: undefined }, port: 3773, @@ -79,8 +196,8 @@ describe("server ownership", () => { const state = yield* makeState; yield* ServerRuntimeState.persistServerRuntimeState({ path: statePath, - // @effect-diagnostics-next-line globalDateInEffect:off - Legacy identity uses the real process start time, not TestClock. - state: { ...state, startedAt: new Date().toISOString() }, + // Any record time after this process started proves the PID was not reused. + state: { ...state, startedAt: "2100-01-01T00:00:00.000Z" }, }); const refused = yield* Effect.scoped(ServerOwnership.acquireServerOwnership(statePath)).pipe( Effect.flip, @@ -115,189 +232,168 @@ describe("server ownership", () => { assert.equal(Option.getOrThrow(remaining).ownerId, "other-owner"); }).pipe(Effect.provide(layerOwnership)), ); -}); - -describe("serverRuntimeState", () => { - it.effect("persists and reads the runtime state", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-runtime-state-test-", - }); - const statePath = path.join(root, "runtime", "server.json"); - const state: ServerRuntimeState.PersistedServerRuntimeState = { - version: 1, - pid: 123, - host: "127.0.0.1", - port: 4_971, - origin: "http://127.0.0.1:4971", - devUrl: "http://localhost:5733/", - publicUrl: "https://t3.example.com/", - startedAt: "2026-06-20T00:00:00.000Z", - }; - - yield* ServerRuntimeState.persistServerRuntimeState({ path: statePath, state }); - const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); - - assert.deepEqual(Option.getOrThrow(restored), state); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("records the dev web URL when the server fronts a dev server", () => - Effect.gen(function* () { - const state = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: new URL("http://localhost:5733") }, - port: 13_773, - }); - - assert.equal(state.devUrl, "http://localhost:5733/"); - assert.equal(state.origin, "http://127.0.0.1:13773"); - - const withoutDev = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: undefined }, - port: 13_773, - }); - assert.isFalse("devUrl" in withoutDev); - }), - ); - - it.effect("marks a service-supervised server so CLIs can tell it from a manual one", () => - Effect.gen(function* () { - const managed = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: undefined }, - port: 13_773, - serviceManaged: true, - }); - const manual = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: undefined }, - port: 13_773, - }); - - assert.isTrue(managed.serviceManaged); - assert.isFalse("serviceManaged" in manual); - }), - ); - it.effect("treats a missing runtime state file as absent", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-runtime-state-test-", - }); - - const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState( - path.join(root, "missing.json"), - ); - - assert.isTrue(Option.isNone(restored)); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("preserves malformed state decode failures", () => { - const logs: CapturedLog[] = []; - const logger = Logger.make(({ fiber, message }) => { - logs.push({ - message, - annotations: fiber.getRef(References.CurrentLogAnnotations), - }); - }); - - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-runtime-state-test-", - }); - const statePath = path.join(root, "server.json"); - yield* fileSystem.writeFileString(statePath, "{not json"); - - const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); + describe("beside an installed boot service", () => { + it.effect("refuses an old active or scheduled service even without its runtime record", () => + Effect.gen(function* () { + for (const fixture of [ + { + platform: "darwin", + outputs: { "launchctl print": { code: 0 } }, + }, + { + platform: "darwin", + outputs: { + "launchctl print": { code: 113 }, + "launchctl print-disabled": { + code: 0, + stdout: '"com.t3tools.t3code.service" => false', + }, + }, + }, + { + platform: "linux", + outputs: { + // Restart= keeps an otherwise disabled service alive during its restart delay. + "systemctl is-active": { code: 3, stdout: "activating\n" }, + "systemctl is-enabled": { code: 1, stdout: "disabled\n" }, + }, + }, + { + platform: "linux", + outputs: { + "systemctl is-active": { code: 3, stdout: "inactive\n" }, + "systemctl is-enabled": { code: 0, stdout: "enabled\n" }, + }, + }, + { + platform: "darwin", + outputs: { "launchctl print": { code: 0 } }, + environment: { [SERVICE_LAUNCHER_CONTEXT_ENV]: "{}" }, + }, + ] as const) { + const { result, t3Home } = yield* startBesideBootService(fixture); + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") return; + assert.equal(result.failure._tag, "OlderBootServiceError"); + assert.equal(Runtime.getErrorExitCode(result.failure), 78); + assert.include(result.failure.message, t3Home); + assert.include(result.failure.message, "t3 update"); + assert.include(result.failure.message, "t3 service restart"); + assert.include(result.failure.message, "No server was stopped"); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); - assert.isTrue(Option.isNone(restored)); - assert.equal(logs[0]?.message, `Failed to decode server runtime state at ${statePath}.`); - const error = logs[0]?.annotations.cause; - assert.isTrue(isServerRuntimeStateError(error)); - if (isServerRuntimeStateError(error)) { - assert.equal(error.operation, "decode"); - assert.equal(error.statePath, statePath); - assert.equal(error.message, `Failed to decode server runtime state at ${statePath}.`); - assert.deepInclude(error.cause, { _tag: "SchemaError" }); - } - }).pipe( - Effect.provide( - Layer.merge(NodeServices.layer, Logger.layer([logger], { mergeWithExisting: false })), - ), + it.effect( + "refuses an active npm-installed service with no standalone executable or runtime record", + () => + Effect.gen(function* () { + const { result } = yield* startBesideBootService({ + platform: "linux", + activeVersion: "0.0.40", + npmRuntime: true, + outputs: { + "systemctl is-active": { code: 0, stdout: "active\n" }, + }, + }); + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") return; + assert.equal(result.failure._tag, "OlderBootServiceError"); + assert.equal(Runtime.getErrorExitCode(result.failure), 78); + }).pipe(Effect.provide(NodeServices.layer)), ); - }); - it.effect("preserves runtime state read failures", () => { - const logs: CapturedLog[] = []; - const logger = Logger.make(({ fiber, message }) => { - logs.push({ - message, - annotations: fiber.getRef(References.CurrentLogAnnotations), - }); - }); + it.effect("refuses while the loaded service has not restarted into its lock-aware update", () => + Effect.gen(function* () { + const { result } = yield* startBesideBootService({ + platform: "darwin", + restartPending: true, + activeVersion: "0.1.0", + outputs: { + "launchctl print": { code: 0 }, + }, + }); + assert.equal(result._tag === "Failure" && result.failure._tag, "OlderBootServiceError"); + }).pipe(Effect.provide(NodeServices.layer)), + ); - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-runtime-state-test-", - }); - const statePath = path.join(root, "server.json"); - yield* fileSystem.makeDirectory(statePath); + it.effect("allows lock-aware stable services that are loaded or stopped and enabled", () => + Effect.gen(function* () { + for (const fixture of [ + { + platform: "darwin", + outputs: { + "launchctl print": { code: 0 }, + }, + }, + { + platform: "linux", + outputs: { + "systemctl is-active": { code: 3, stdout: "inactive\n" }, + "systemctl is-enabled": { code: 0, stdout: "enabled\n" }, + }, + }, + ] as const) { + const { result } = yield* startBesideBootService({ ...fixture, activeVersion: "0.1.0" }); + assert.equal(result._tag, "Success"); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); - const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); + it.effect("allows services that cannot compete for this home", () => + Effect.gen(function* () { + const outputs = { + "launchctl print": { code: 0 }, + }; + for (const fixture of [ + { platform: "darwin", outputs, unitServesOtherHome: true }, + { + platform: "darwin", + outputs, + environment: { [SERVICE_LAUNCHER_CONTEXT_ENV]: "{}" }, + connected: true, + }, + { + platform: "darwin", + outputs: { + "launchctl print": { code: 113 }, + "launchctl print-disabled": { + code: 0, + stdout: '"com.t3tools.t3code.service" => disabled', + }, + }, + }, + { + platform: "linux", + outputs: { + "systemctl is-active": { code: 3, stdout: "inactive\n" }, + "systemctl is-enabled": { code: 1, stdout: "disabled\n" }, + }, + }, + ] as const) { + const { result } = yield* startBesideBootService(fixture); + assert.equal(result._tag, "Success"); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); - assert.isTrue(Option.isNone(restored)); - assert.equal(logs[0]?.message, `Failed to read server runtime state at ${statePath}.`); - const error = logs[0]?.annotations.cause; - assert.isTrue(isServerRuntimeStateError(error)); - if (isServerRuntimeStateError(error)) { - assert.equal(error.operation, "read"); - assert.equal(error.statePath, statePath); - assert.equal(error.message, `Failed to read server runtime state at ${statePath}.`); - assert.deepInclude(error.cause, { _tag: "PlatformError" }); - } - }).pipe( - Effect.provide( - Layer.merge(NodeServices.layer, Logger.layer([logger], { mergeWithExisting: false })), - ), + it.effect("allows startup when the service configuration or manager is unavailable", () => + Effect.gen(function* () { + for (const fixture of [ + { outputs: {} }, + { + outputs: { + "launchctl print": { code: 113 }, + "launchctl print-disabled": { code: 1 }, + }, + }, + { outputs: { "launchctl print": { code: 0 } }, missingServiceState: true }, + ]) { + const { result } = yield* startBesideBootService({ platform: "darwin", ...fixture }); + assert.equal(result._tag, "Success"); + } + }).pipe(Effect.provide(NodeServices.layer)), ); }); - - it.effect("preserves runtime state persistence failures", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-runtime-state-test-", - }); - const blockedDirectory = path.join(root, "not-a-directory"); - const statePath = path.join(blockedDirectory, "server.json"); - yield* fileSystem.writeFileString(blockedDirectory, "blocked"); - - const error = yield* ServerRuntimeState.persistServerRuntimeState({ - path: statePath, - state: { - version: 1, - pid: 123, - port: 4_971, - origin: "http://127.0.0.1:4971", - startedAt: "2026-06-20T00:00:00.000Z", - }, - }).pipe(Effect.flip); - - assert.isTrue(isServerRuntimeStateError(error)); - if (isServerRuntimeStateError(error)) { - assert.equal(error.operation, "persist"); - assert.equal(error.statePath, statePath); - assert.equal(error.message, `Failed to persist server runtime state at ${statePath}.`); - assert.deepInclude(error.cause, { _tag: "PlatformError" }); - } - }).pipe(Effect.provide(NodeServices.layer)), - ); }); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 08f5b8763708..98e462f48d49 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -87,30 +87,31 @@ export const persistServerRuntimeState = (input: { ), ); -export const clearPersistedServerRuntimeState = (path: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* fs.remove(path, { force: true }).pipe( - Effect.mapError( - (cause) => - new ServerRuntimeStateError({ - operation: "clear", - statePath: path, - cause, +export const clearPersistedServerRuntimeState = Effect.fn( + "serverRuntimeState.clearPersistedServerRuntimeState", +)(function* (path: string) { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, { force: true }).pipe( + Effect.mapError( + (cause) => + new ServerRuntimeStateError({ + operation: "clear", + statePath: path, + cause, + }), + ), + Effect.catchTags({ + ServerRuntimeStateError: (error) => + Effect.logWarning(error.message).pipe( + Effect.annotateLogs({ + operation: error.operation, + statePath: error.statePath, + cause: error, }), - ), - Effect.catchTags({ - ServerRuntimeStateError: (error) => - Effect.logWarning(error.message).pipe( - Effect.annotateLogs({ - operation: error.operation, - statePath: error.statePath, - cause: error, - }), - ), - }), - ); - }); + ), + }), + ); +}); /** * Signal 0 delivers nothing; it only reports whether the pid exists. EPERM @@ -126,8 +127,10 @@ export const isProcessAlive = (pid: number): boolean => { } }; -export const readPersistedServerRuntimeState = (path: string) => - Effect.gen(function* () { +export const readPersistedServerRuntimeState = Effect.fn( + "serverRuntimeState.readPersistedServerRuntimeState", +)( + function* (path: string) { const fs = yield* FileSystem.FileSystem; const raw = yield* fs.readFileString(path).pipe( Effect.matchEffect({ @@ -164,16 +167,16 @@ export const readPersistedServerRuntimeState = (path: string) => }), ), ); - }).pipe( - Effect.catchTags({ - ServerRuntimeStateError: (error) => - Effect.logWarning(error.message).pipe( - Effect.annotateLogs({ - operation: error.operation, - statePath: error.statePath, - cause: error, - }), - Effect.as(Option.none()), - ), - }), - ); + }, + Effect.catchTags({ + ServerRuntimeStateError: (error) => + Effect.logWarning(error.message).pipe( + Effect.annotateLogs({ + operation: error.operation, + statePath: error.statePath, + cause: error, + }), + Effect.as(Option.none()), + ), + }), +); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 5287c5a3dbd9..ac4e0d394284 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -8,14 +8,12 @@ import { ProviderInstanceId, resolveProviderInstanceEnabled, ServerSettings, - ServerSettingsPatch, UsageLimitSourceId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -24,7 +22,6 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import { sourceControlHostSecretName } from "./sourceControl/sourceControlHostSecrets.ts"; @@ -34,7 +31,6 @@ import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerSettingsModule from "./serverSettings.ts"; import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; -const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)); @@ -216,6 +212,29 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect("moves an idle compaction mode saved in the legacy switch to its own field", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const service = yield* ServerSettingsModule.ServerSettingsService; + yield* fs.writeFileString(config.settingsPath, `{ "compactIdleThreads": "off" }`); + + const settings = yield* service.getSettings; + // Shipped clients decode the switch as a boolean and drop the config otherwise. + assert.strictEqual(settings.compactIdleThreads, false); + assert.equal(settings.idleCompaction, "off"); + }).pipe(Effect.provide(layerServerSettings())), + ); + + it.effect("tells clients it reads idle compaction modes before any settings are saved", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const settings = yield* service.getSettings; + assert.equal(settings.idleCompaction, "onReturn"); + assert.strictEqual(settings.compactIdleThreads, false); + }).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("saves through a symlinked settings file without replacing the link", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -238,154 +257,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("reloads when the destination of a symlinked settings file changes", () => - Effect.scoped( - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const service = yield* ServerSettingsModule.ServerSettingsService; - const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); - const linkedSettingsPath = path.join(dotfiles, "settings.json"); - yield* fs.writeFileString(linkedSettingsPath, `{ "responseStreamingMode": "turn" }`); - yield* fs.remove(config.settingsPath, { force: true }); - yield* fs.symlink(linkedSettingsPath, config.settingsPath); - yield* service.start; - const changes = yield* service.subscribeChanges; - - yield* writeFileStringAtomically({ - filePath: linkedSettingsPath, - contents: `{ "responseStreamingMode": "paragraph" }`, - }); - - const change = yield* changes.pipe(Stream.runHead, Effect.timeout("2 seconds")); - assert.equal(Option.getOrUndefined(change)?.responseStreamingMode, "paragraph"); - }), - ).pipe(TestClock.withLive, Effect.provide(layerServerSettings())), - ); - - it.effect("follows a settings link that is repointed to another directory", () => - Effect.scoped( - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const service = yield* ServerSettingsModule.ServerSettingsService; - const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); - const firstSettingsPath = path.join(dotfiles, "first", "settings.json"); - const secondSettingsPath = path.join(dotfiles, "second", "settings.json"); - yield* fs.makeDirectory(path.dirname(firstSettingsPath), { recursive: true }); - yield* fs.makeDirectory(path.dirname(secondSettingsPath), { recursive: true }); - yield* fs.writeFileString(firstSettingsPath, `{ "responseStreamingMode": "turn" }`); - yield* fs.writeFileString(secondSettingsPath, `{ "responseStreamingMode": "paragraph" }`); - yield* fs.remove(config.settingsPath, { force: true }); - yield* fs.symlink(firstSettingsPath, config.settingsPath); - yield* service.start; - - const repointChanges = yield* service.subscribeChanges; - yield* fs.remove(config.settingsPath); - yield* fs.symlink(secondSettingsPath, config.settingsPath); - const repointed = yield* repointChanges.pipe(Stream.runHead, Effect.timeout("2 seconds")); - assert.equal(Option.getOrUndefined(repointed)?.responseStreamingMode, "paragraph"); - - const editChanges = yield* service.subscribeChanges; - yield* writeFileStringAtomically({ - filePath: secondSettingsPath, - contents: `{ "responseStreamingMode": "turn" }`, - }); - const edited = yield* editChanges.pipe(Stream.runHead, Effect.timeout("2 seconds")); - assert.equal(Option.getOrUndefined(edited)?.responseStreamingMode, "turn"); - }), - ).pipe(TestClock.withLive, Effect.provide(layerServerSettings())), - ); - - it.effect("reloads when a dangling settings link gets its destination", () => - Effect.scoped( - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const service = yield* ServerSettingsModule.ServerSettingsService; - const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); - const linkedSettingsPath = path.join(dotfiles, "not-yet", "settings.json"); - yield* fs.remove(config.settingsPath, { force: true }); - yield* fs.symlink(linkedSettingsPath, config.settingsPath); - yield* service.start; - const changes = yield* service.subscribeChanges; - - yield* writeFileStringAtomically({ - filePath: linkedSettingsPath, - contents: `{ "responseStreamingMode": "paragraph" }`, - }); - - const change = yield* changes.pipe(Stream.runHead, Effect.timeout("2 seconds")); - assert.equal(Option.getOrUndefined(change)?.responseStreamingMode, "paragraph"); - }), - ).pipe(TestClock.withLive, Effect.provide(layerServerSettings())), - ); - - it.effect("preserves context when reading a provider environment secret fails", () => { - const platformCause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFile", - pathOrDescriptor: "provider environment secret", - description: "Secret backend unavailable.", - }); - const cause = new ServerSecretStore.SecretStoreReadError({ - resource: "provider environment secret", - cause: platformCause, - }); - const layerConfig = Layer.fresh( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-server-settings-secret-failure-test-", - }), - ); - const layerSettings = ServerSettingsModule.layer.pipe( - Layer.provide(layerFailingSecretStore(cause)), - Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), - Layer.provideMerge(layerConfig), - ); - - return Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - yield* fileSystem.writeFileString( - serverConfig.settingsPath, - '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"OPENROUTER_API_KEY","value":"","sensitive":true,"valueRedacted":true}],"config":{}}}}', - ); - - const error = yield* Effect.flip(serverSettings.getSettings); - - assert.deepInclude(error, { - _tag: "ServerSettingsError", - operation: "read-secret", - providerInstanceId: "codex_personal", - environmentVariable: "OPENROUTER_API_KEY", - }); - assert.strictEqual(error.cause, cause); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(layerSettings)); - }); - - it.effect("identifies provider history query failures", () => - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const sql = yield* SqlClient.SqlClient; - yield* sql`DROP TABLE projection_thread_sessions`; - - const error = yield* Effect.flip(serverSettings.getSettings); - - assert.deepInclude(error, { - _tag: "ServerSettingsError", - operation: "read-provider-history", - settingsPath: serverConfig.settingsPath, - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect("retries a failed settings read instead of keeping the failure", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -408,23 +279,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("decodes nested settings patches", () => - Effect.gen(function* () { - assert.deepEqual( - yield* decodeSettingsPatch({ - textGenerationModelSelection: { - options: [{ id: "fastMode", value: false }], - }, - }), - { - textGenerationModelSelection: { - options: [{ id: "fastMode", value: false }], - }, - }, - ); - }), - ); - it.effect( "decodes legacy object-shaped textGenerationModelSelection.options from settings.json", () => @@ -488,6 +342,70 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect("keeps an external settings edit that the watcher has not reloaded yet", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* serverSettings.getSettings; + + yield* writeFileStringAtomically({ + filePath: config.settingsPath, + contents: `{ "responseStreamingMode": "turn" }`, + }); + yield* serverSettings.updateSettings({ + observability: { otlpTracesUrl: "http://localhost:4318/v1/traces" }, + }); + + const saved = yield* decodeServerSettingsJson(yield* fs.readFileString(config.settingsPath)); + assert.equal(saved.responseStreamingMode, "turn"); + assert.equal(saved.observability.otlpTracesUrl, "http://localhost:4318/v1/traces"); + + yield* writeFileStringAtomically({ + filePath: config.settingsPath, + contents: `{ "responseStreamingMode": "paragraph" }`, + }); + const instanceId = ProviderInstanceId.make("acpRegistry_kilo"); + yield* serverSettings.updateProviderInstance({ + operation: "upsert", + instanceId, + instance: { + driver: ProviderDriverKind.make("acpRegistry"), + displayName: "Kilo", + config: { agentId: "kilo", distribution: "auto" }, + }, + }); + + const afterProviderSave = yield* decodeServerSettingsJson( + yield* fs.readFileString(config.settingsPath), + ); + assert.equal(afterProviderSave.responseStreamingMode, "paragraph"); + assert.equal(afterProviderSave.providerInstances[instanceId]?.displayName, "Kilo"); + }).pipe(Effect.provide(layerServerSettings())), + ); + + it.effect("refuses to save over a settings file it cannot parse", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* serverSettings.getSettings; + const raw = `{ "responseStreamingMode": "turn", `; + yield* fs.writeFileString(config.settingsPath, raw); + + const error = yield* Effect.flip( + serverSettings.updateSettings({ + observability: { otlpTracesUrl: "http://localhost:4318/v1/traces" }, + }), + ); + + assert.equal(error.operation, "read-file"); + assert.equal(error.settingsPath, config.settingsPath); + assert.instanceOf(error.cause, Error); + assert.equal(yield* fs.readFileString(config.settingsPath), raw); + }).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("creates provider instances atomically without overwriting a concurrent add", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; @@ -627,33 +545,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Effect.provide(layerServerSettings())), ); - it.effect("persists and broadcasts thread settlement settings", () => - Effect.scoped( - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const changes = yield* serverSettings.subscribeChanges; - - const next = yield* serverSettings.updateSettings({ - sidebarAutoSettleAfterDays: null, - sidebarAutoSettleOnMerge: false, - }); - const change = Option.getOrUndefined(yield* Stream.runHead(changes)); - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - - const persisted = JSON.parse(raw) as Record; - - assert.strictEqual(next.sidebarAutoSettleAfterDays, null); - assert.isFalse(next.sidebarAutoSettleOnMerge); - assert.strictEqual(change?.sidebarAutoSettleAfterDays, null); - assert.isFalse(change?.sidebarAutoSettleOnMerge); - assert.strictEqual(persisted.sidebarAutoSettleAfterDays, null); - assert.isFalse(persisted.sidebarAutoSettleOnMerge); - }), - ).pipe(Effect.provide(layerServerSettings())), - ); - it.effect("preserves model when switching providers via textGenerationModelSelection", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; @@ -689,31 +580,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("preserves custom provider instance text generation selections", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - - const next = yield* serverSettings.updateSettings({ - providerInstances: { - [ProviderInstanceId.make("claude_openrouter")]: { - driver: ProviderDriverKind.make("claudeAgent"), - enabled: true, - config: { customModels: ["openai/gpt-5.5"] }, - }, - }, - textGenerationModelSelection: { - instanceId: ProviderInstanceId.make("claude_openrouter"), - model: "openai/gpt-5.5", - }, - }); - - assert.deepEqual(next.textGenerationModelSelection, { - instanceId: ProviderInstanceId.make("claude_openrouter"), - model: "openai/gpt-5.5", - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect( "keeps a custom instance's selection when the driver's default instance is disabled", () => @@ -867,32 +733,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("preserves enabled text generation selections for non-built-in drivers", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const instanceId = ProviderInstanceId.make("openrouter_text"); - - const next = yield* serverSettings.updateSettings({ - providerInstances: { - [instanceId]: { - driver: ProviderDriverKind.make("openrouter"), - enabled: true, - config: { customModels: ["openai/gpt-5.5"] }, - }, - }, - textGenerationModelSelection: { - instanceId, - model: "openai/gpt-5.5", - }, - }); - - assert.deepEqual(next.textGenerationModelSelection, { - instanceId, - model: "openai/gpt-5.5", - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect( "preserves the source control writer selection when its provider instance is disabled", () => @@ -959,76 +799,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("drops stale text generation options when resetting model selection", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - - yield* serverSettings.updateSettings({ - textGenerationModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - options: createModelSelection( - ProviderInstanceId.make("codex"), - DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - [ - { id: "reasoningEffort", value: "high" }, - { id: "fastMode", value: true }, - ], - ).options!, - }, - }); - - const next = yield* serverSettings.updateSettings({ - textGenerationModelSelection: { - instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId, - model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - }, - }); - - assert.deepEqual(next.textGenerationModelSelection, { - instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId, - model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("replaces provider instance maps when clearing optional fields", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const codexId = ProviderInstanceId.make("codex"); - - yield* serverSettings.updateSettings({ - providerInstances: { - [codexId]: { - driver: ProviderDriverKind.make("codex"), - displayName: "Codex Work", - accentColor: "#7c3aed", - enabled: true, - config: { homePath: "~/.codex" }, - }, - }, - }); - - const next = yield* serverSettings.updateSettings({ - providerInstances: { - [codexId]: { - driver: ProviderDriverKind.make("codex"), - displayName: "Codex Work", - enabled: true, - config: { homePath: "~/.codex" }, - }, - }, - }); - - assert.deepEqual(next.providerInstances[codexId], { - driver: ProviderDriverKind.make("codex"), - displayName: "Codex Work", - enabled: true, - config: { homePath: "~/.codex" }, - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect("moves customized legacy provider settings into default instances on load", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -1155,20 +925,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("runs provider history only for files that still carry the legacy map", () => - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); - yield* recordProviderUsage("grok"); - - const settings = yield* serverSettings.getSettings; - - assert.deepEqual(settings.providerInstances, {}); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect("migrates legacy providers from an invalid file without rewriting it", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -1190,22 +946,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("skips a disabled provider instance when picking the text generation fallback", () => - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - yield* fileSystem.writeFileString( - serverConfig.settingsPath, - '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}}}', - ); - - const settings = yield* serverSettings.getSettings; - - assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect("keeps a default-off provider's new instance disabled and sparse on disk", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -1264,85 +1004,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("folds in-config enabled flags arriving through updates", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const grokId = ProviderInstanceId.make("grok"); - - const next = yield* serverSettings.updateSettings({ - providerInstances: { - [grokId]: { - driver: ProviderDriverKind.make("grok"), - enabled: true, - config: { enabled: false, binaryPath: "/opt/grok" }, - }, - }, - }); - - assert.deepEqual(next.providerInstances[grokId], { - driver: ProviderDriverKind.make("grok"), - enabled: false, - config: { binaryPath: "/opt/grok" }, - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("trims observability settings when updates are applied", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - - const next = yield* serverSettings.updateSettings({ - addProjectBaseDirectory: " ~/Development ", - observability: { - otlpTracesUrl: " http://localhost:4318/v1/traces ", - otlpMetricsUrl: " http://localhost:4318/v1/metrics ", - otlpLogsUrl: " http://localhost:4318/v1/logs ", - }, - }); - - assert.equal(next.addProjectBaseDirectory, "~/Development"); - assert.deepEqual(next.observability, { - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", - otlpLogsUrl: "http://localhost:4318/v1/logs", - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("writes only non-default settings to disk", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - yield* serverSettings.updateSettings({ - addProjectBaseDirectory: "~/Development", - observability: { - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", - }, - automaticGitFetchInterval: Duration.seconds(10), - }); - - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - assert.deepEqual(JSON.parse(raw), { - addProjectBaseDirectory: "~/Development", - observability: { - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", - }, - backgroundActivity: { - schemaVersion: 1, - profile: "custom", - baseProfile: "balanced", - overrides: { - automaticGitFetchInterval: 10_000, - }, - }, - automaticGitFetchInterval: 10_000, - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect("keeps the inline value on disk when secret migration fails", () => { const cause = new ServerSecretStore.SecretStorePersistError({ resource: "provider environment secret", @@ -1526,6 +1187,17 @@ it.layer(NodeServices.layer)("server settings", (it) => { enabled: true, }, }, + easDeviceHosts: [ + { + kind: "eas", + id: "eas", + label: "EAS Simulator", + accessToken: "expo-secret", + maxSessionMinutes: 30, + idleMinutes: 10, + maxConcurrentSessions: 2, + }, + ], }); const values = [ "config-secret", @@ -1534,6 +1206,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { "bitbucket-secret", "gitcafe-secret", "quota-secret", + "expo-secret", ]; const client = ServerSettingsModule.redactServerSettingsForClient(saved); const disk = yield* fs.readFileString(config.settingsPath); @@ -1548,6 +1221,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { providerInstances: client.providerInstances, sourceControlHosts: client.sourceControlHosts, usageLimitSources: client.usageLimitSources, + easDeviceHosts: client.easDeviceHosts, }); for (const value of values) assert.include(JSON.stringify(echoed), value); const renamed = yield* service.updateProviderInstance({ @@ -1667,69 +1341,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("stores sensitive provider instance environment values outside settings.json", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const instanceId = ProviderInstanceId.make("codex_personal"); - - const next = yield* serverSettings.updateSettings({ - providerInstances: { - [instanceId]: { - driver: ProviderDriverKind.make("codex"), - environment: [ - { name: "OPENROUTER_API_KEY", value: "sk-or-secret", sensitive: true }, - { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, - ], - config: {}, - }, - }, - }); - - assert.deepEqual(next.providerInstances[instanceId]?.environment, [ - { - name: "OPENROUTER_API_KEY", - value: "sk-or-secret", - sensitive: true, - valueRedacted: true, - }, - { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, - ]); - - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - assert.notInclude(raw, "sk-or-secret"); - assert.deepEqual(JSON.parse(raw).providerInstances.codex_personal.environment, [ - { - name: "OPENROUTER_API_KEY", - value: "", - sensitive: true, - valueRedacted: true, - }, - { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, - ]); - - const roundTripped = yield* serverSettings.updateSettings({ - providerInstances: { - [instanceId]: { - driver: ProviderDriverKind.make("codex"), - displayName: "Codex Personal", - environment: [ - { name: "OPENROUTER_API_KEY", value: "", sensitive: true, valueRedacted: true }, - { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, - ], - config: {}, - }, - }, - }); - - assert.equal( - roundTripped.providerInstances[instanceId]?.environment?.[0]?.value, - "sk-or-secret", - ); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect( "keeps source control host secrets in the secret store and tells clients only that one is set", () => diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index ad9a81f2d204..65c114c75ca8 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -15,6 +15,8 @@ import { ServerSettings, ServerSettingsError, type ServerSettingsPatch, + globalIdleCompaction, + withIdleCompaction, } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; @@ -117,6 +119,7 @@ const normalizeServerSettings = ( encodeServerSettings(settings).pipe( Effect.flatMap(decodeServerSettings), Effect.map(foldProviderInstanceEnabledFlags), + Effect.map((next) => withIdleCompaction(next, globalIdleCompaction(next))), Effect.map((next) => ({ ...next, ...deriveLegacyProjectOverrides(next) })), Effect.mapError( (cause) => @@ -203,62 +206,59 @@ export class ServerSettingsService extends Context.Service< static readonly layerTest = (overrides: DeepPartial = {}) => layerTest(overrides); } -const makeTest = (overrides: DeepPartial = {}) => - Effect.gen(function* () { - const { automaticGitFetchInterval, providerHealthRefreshInterval, ...overridesForMerge } = - overrides; - const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge); - const initialSettings = yield* normalizeServerSettings({ - ...merged, - ...(automaticGitFetchInterval !== undefined - ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration } - : {}), - ...(providerHealthRefreshInterval !== undefined - ? { providerHealthRefreshInterval: providerHealthRefreshInterval as Duration.Duration } - : {}), - }); - const currentSettingsRef = yield* Ref.make(initialSettings); - const writeSemaphore = yield* Semaphore.make(1); - const getSettings = Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)); - - const updateTestSettings = ( - update: (current: ServerSettings) => Effect.Effect, - ): Effect.Effect => - writeSemaphore.withPermits(1)( - Ref.get(currentSettingsRef).pipe( - Effect.flatMap(update), - Effect.flatMap(normalizeServerSettings), - Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), - Effect.map(resolveTextGenerationProvider), - ), - ); - - return { - start: Effect.void, - ready: Effect.void, - getSettings, - updateSettings: (patch) => - updateTestSettings((currentSettings) => - Effect.succeed(applyServerSettingsPatch(currentSettings, patch)), - ), - updateProviderInstance: (mutation, patch = {}) => - updateTestSettings((currentSettings) => - Effect.gen(function* () { - yield* ensureProviderInstanceMutationAllowed( - currentSettings, - mutation, - "test settings", - ); - const patched = applyServerSettingsPatch(currentSettings, patch); - return applyProviderInstanceMutation(patched, mutation); - }), - ), - withSettingsSnapshot: (use) => - writeSemaphore.withPermits(1)(getSettings.pipe(Effect.flatMap(use))), - streamChanges: Stream.empty, - subscribeChanges: Effect.succeed(Stream.empty), - } satisfies ServerSettingsService["Service"]; +const makeTest = Effect.fn("serverSettings.makeTest")(function* ( + overrides: DeepPartial = {}, +) { + const { automaticGitFetchInterval, providerHealthRefreshInterval, ...overridesForMerge } = + overrides; + const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge); + const initialSettings = yield* normalizeServerSettings({ + ...merged, + ...(automaticGitFetchInterval !== undefined + ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration } + : {}), + ...(providerHealthRefreshInterval !== undefined + ? { providerHealthRefreshInterval: providerHealthRefreshInterval as Duration.Duration } + : {}), }); + const currentSettingsRef = yield* Ref.make(initialSettings); + const writeSemaphore = yield* Semaphore.make(1); + const getSettings = Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)); + + const updateTestSettings = ( + update: (current: ServerSettings) => Effect.Effect, + ): Effect.Effect => + writeSemaphore.withPermits(1)( + Ref.get(currentSettingsRef).pipe( + Effect.flatMap(update), + Effect.flatMap(normalizeServerSettings), + Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), + Effect.map(resolveTextGenerationProvider), + ), + ); + + return { + start: Effect.void, + ready: Effect.void, + getSettings, + updateSettings: (patch) => + updateTestSettings((currentSettings) => + Effect.succeed(applyServerSettingsPatch(currentSettings, patch)), + ), + updateProviderInstance: (mutation, patch = {}) => + updateTestSettings((currentSettings) => + Effect.gen(function* () { + yield* ensureProviderInstanceMutationAllowed(currentSettings, mutation, "test settings"); + const patched = applyServerSettingsPatch(currentSettings, patch); + return applyProviderInstanceMutation(patched, mutation); + }), + ), + withSettingsSnapshot: (use) => + writeSemaphore.withPermits(1)(getSettings.pipe(Effect.flatMap(use))), + streamChanges: Stream.empty, + subscribeChanges: Effect.succeed(Stream.empty), + } satisfies ServerSettingsService["Service"]; +}); export const layerTest = (overrides: DeepPartial = {}) => Layer.effect(ServerSettingsService, makeTest(overrides)); @@ -625,8 +625,8 @@ const make = Effect.gen(function* () { ), ); - const moveInlineSettingsSecrets = (settings: ServerSettings) => - Effect.gen(function* () { + const moveInlineSettingsSecrets = Effect.fn("serverSettings.moveInlineSettingsSecrets")( + function* (settings: ServerSettings) { const pending: SettingsSecret[] = []; mapSettingsSecrets(settings, (secret) => { if (secret.kind === "field" && !secret.redacted && secret.value.length > 0) { @@ -652,15 +652,14 @@ const make = Effect.gen(function* () { return mapSettingsSecrets(settings, (secret) => moved.has(secret.secretName) ? { value: "", redacted: true } : secret, ); - }); + }, + ); const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; let legacyProviders: Readonly> | undefined; let legacySourceControl: Parameters[1] = {}; - // A file that failed to decode must stay on disk for the user to repair; - // the fold below only writes when it started from the file's real contents. - let settingsFileTrusted = true; + let parseFailure = Option.none(); if (yield* readConfigExists) { const raw = yield* readRawConfig; @@ -675,10 +674,10 @@ const make = Effect.gen(function* () { } if (decoded._tag === "Failure" || legacySettings._tag === "Failure") { const failure = decoded._tag === "Failure" ? decoded : legacySettings; - settingsFileTrusted = false; // The file may hold an opt-out we can no longer read; never resume threads on a guess. settings = { ...DEFAULT_SERVER_SETTINGS, continueThreadsAfterServerUpdate: false }; if (failure._tag === "Failure") { + parseFailure = Option.some(Cause.squash(failure.cause)); yield* Effect.logWarning("failed to parse settings.json, using defaults", { path: settingsPath, issues: Cause.pretty(failure.cause), @@ -689,7 +688,13 @@ const make = Effect.gen(function* () { settings = decoded.value; } } + // Settings saved by a server that kept the mode in `compactIdleThreads` move + // it to `idleCompaction`, so that switch goes to clients as a boolean. The + // defaults gain the field too, which tells clients this server reads modes. + settings = withIdleCompaction(settings, globalIdleCompaction(settings)); + // A file that failed to decode must stay on disk for the user to repair. + const settingsFileTrusted = Option.isNone(parseFailure); const providerHistory = yield* sql<{ readonly providerName: string; readonly providerInstanceId: string | null; @@ -787,23 +792,23 @@ const make = Effect.gen(function* () { yield* secretStore.remove(move.from).pipe(Effect.ignore); } } - return migrated; + return { settings: migrated, parseFailure }; }); - const settingsCache = yield* Cache.makeWith( - () => loadSettingsFromDisk, - { - capacity: 1, - timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), - }, - ); + const settingsCache = yield* Cache.makeWith((_key: typeof cacheKey) => loadSettingsFromDisk, { + capacity: 1, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), + }); - const getSettingsFromCache = Cache.get(settingsCache, cacheKey); + const getSettingsFromCache = Cache.get(settingsCache, cacheKey).pipe( + Effect.map((loaded) => loaded.settings), + ); + const reloadSettings = Cache.invalidate(settingsCache, cacheKey).pipe( + Effect.andThen(Cache.get(settingsCache, cacheKey)), + ); - const materializeSettingsSecrets = ( - settings: ServerSettings, - ): Effect.Effect => - Effect.gen(function* () { + const materializeSettingsSecrets = Effect.fn("serverSettings.materializeSettingsSecrets")( + function* (settings: ServerSettings): Effect.fn.Return { const storedSecrets = new Map(); const pending = new Map(); mapSettingsSecrets(settings, (secret) => { @@ -835,7 +840,8 @@ const make = Effect.gen(function* () { : secret.value, redacted: secret.sensitive && secret.kind === "environment" && secret.redacted, })); - }); + }, + ); const materializeChanges = (changes: Stream.Stream) => changes.pipe( @@ -1001,7 +1007,14 @@ const make = Effect.gen(function* () { ): Effect.Effect => writeSemaphore.withPermits(1)( Effect.gen(function* () { - const current = yield* getSettingsFromCache; + const { settings: current, parseFailure } = yield* reloadSettings; + if (Option.isSome(parseFailure)) { + return yield* new ServerSettingsError({ + settingsPath, + operation: "read-file", + cause: parseFailure.value, + }); + } const updated = yield* update(current); const persisted = yield* persistSettingsSecrets(current, updated); const next = yield* normalizeServerSettings(persisted.settings); @@ -1021,7 +1034,7 @@ const make = Effect.gen(function* () { return materializedExit.value; }), ); - yield* Cache.set(settingsCache, cacheKey, next); + yield* Cache.set(settingsCache, cacheKey, { settings: next, parseFailure: Option.none() }); yield* emitChange(next); return resolveTextGenerationProvider(materialized); }), @@ -1038,8 +1051,7 @@ const make = Effect.gen(function* () { const revalidateAndEmit = writeSemaphore.withPermits(1)( Effect.gen(function* () { - yield* Cache.invalidate(settingsCache, cacheKey); - const settings = yield* getSettingsFromCache; + const { settings } = yield* reloadSettings; yield* emitChange(settings); }), ); @@ -1128,8 +1140,7 @@ const make = Effect.gen(function* () { const startup = Effect.gen(function* () { yield* startWatcher; - yield* Cache.invalidate(settingsCache, cacheKey); - yield* getSettingsFromCache; + yield* reloadSettings; }); const startupExit = yield* Effect.exit(startup); diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 3381b7630536..70550f237927 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -12,7 +12,8 @@ import { vi } from "vite-plus/test"; import { databaseBackupBlocker, Launcher, - PREPARED_TIMEOUT_MS, + main, + readServiceEnvironment, type BackupSpaceProbe, readServiceState, writeServiceState, @@ -21,9 +22,9 @@ import { compareExactServiceVersions, decodeServiceState, isExactServiceVersion, + SERVICE_ENV_FILE, SERVICE_LAUNCHER_PROTOCOL, SERVICE_RESTART_PENDING_FILE, - SERVICE_STOP_MARKER_FILE, } from "./cloud/serviceProtocol.ts"; const spawnedChildren = vi.hoisted((): Array => []); @@ -126,20 +127,50 @@ const writeFakeRuntime = ( }); it.layer(NodeServices.layer)("service state persistence", (it) => { - it.effect("durably replaces and strictly reads one state document", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-test-" }); - const statePath = path.join(root, "runtime", "service-state.json"); - const state = { - protocol: SERVICE_LAUNCHER_PROTOCOL, - activeVersion: "0.0.31", - } as const; - - yield* Effect.promise(() => writeServiceState(statePath, state)); - assert.deepEqual(yield* Effect.promise(() => readServiceState(statePath)), state); - }), + it.effect( + "loads optional service.env literally without changing inherited or managed variables", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-env-" }); + const inherited = Object.freeze({ + PATH: "inherited-path", + T3CODE_HOME: root, + T3_BOOT_SERVICE_UNIT: "managed-unit", + T3_SERVICE_LAUNCHER_CONTEXT: "managed-context", + T3CODE_PORT: "ambient", + }); + assert.deepEqual( + yield* Effect.promise(() => readServiceEnvironment(root, inherited)), + inherited, + ); + yield* fs.writeFileString( + path.join(root, SERVICE_ENV_FILE), + [ + "# service configuration", + "export T3CODE_PORT=1234 # inline comment", + 'T3CODE_TOKEN="literal $HOME ${USER} $(whoami) # token"', + 'MULTILINE="first\nsecond"', + "EMPTY=", + "PATH=/bad", + "Path=/bad", + "T3CODE_HOME=/bad", + "t3code_home=/bad", + "T3_BOOT_SERVICE_UNIT=bad", + "t3_boot_service_unit=bad", + "T3_SERVICE_LAUNCHER_CONTEXT=bad", + "t3_service_launcher_context=bad", + ].join("\n"), + ); + assert.deepEqual(yield* Effect.promise(() => readServiceEnvironment(root, inherited)), { + ...inherited, + T3CODE_PORT: "1234", + T3CODE_TOKEN: "literal $HOME ${USER} $(whoami) # token", + MULTILINE: "first\nsecond", + EMPTY: "", + }); + }), ); it.effect("a fresh launcher clears a restart deferred by t3 update", () => @@ -222,34 +253,6 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { }), ); - it.effect("serializes shutdown with launcher recovery", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" }); - const statePath = path.join(root, "runtime", "service-state.json"); - yield* writeFakeRuntime( - fs, - path, - path.join(root, "runtime", "versions", "1.0.0"), - "setInterval(() => {}, 1_000);\n", - ); - yield* Effect.promise(() => - writeServiceState(statePath, { - protocol: SERVICE_LAUNCHER_PROTOCOL, - activeVersion: "1.0.0", - }), - ); - - const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); - const running = launcher.run(); - const stopping = launcher.stop("SIGTERM"); - assert.isTrue(yield* fs.exists(path.join(root, "runtime", SERVICE_STOP_MARKER_FILE))); - yield* Effect.promise(() => stopping); - yield* Effect.promise(() => running); - }), - ); - it.effect("commits only after the trial reports prepared", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -303,21 +306,33 @@ if (context.update?.status === "pending") { }), ); - it.effect("rolls back a trial that reports the wrong update ID", () => + it.effect("rolls back invalid trials with the startup environment", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-rollback-" }); const statePath = path.join(root, "runtime", "service-state.json"); const databasePath = path.join(root, "userdata", "state.sqlite"); + const observationsPath = path.join(root, "environment-observations.jsonl"); + const envPath = path.join(root, SERVICE_ENV_FILE); + yield* fs.writeFileString(envPath, "T3CODE_TEST_SERVICE_TOKEN=initial\n"); + vi.stubEnv("T3CODE_HOME", root); + vi.stubEnv("T3CODE_TEST_SERVICE_TOKEN", "inherited"); + yield* Effect.addFinalizer(() => Effect.sync(() => vi.unstubAllEnvs())); yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); yield* fs.writeFileString(databasePath, "before trial"); const encodedDatabasePath = JSON.stringify(databasePath); const childSource = ` +import { appendFileSync, writeFileSync } from "node:fs"; const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); +appendFileSync(${JSON.stringify(observationsPath)}, JSON.stringify({ + token: process.env.T3CODE_TEST_SERVICE_TOKEN, + update: context.update?.status ?? "active", +}) + "\\n"); if (context.update?.status === "pending") { process.send({ type: "prepared", updateId: "wrong-update" }); } else if (context.update === undefined) { + writeFileSync(${JSON.stringify(envPath)}, "T3CODE_TEST_SERVICE_TOKEN=changed\\n"); process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${encodedDatabasePath} }); setInterval(() => {}, 1_000); } else { @@ -339,9 +354,8 @@ if (context.update?.status === "pending") { }), ); - const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); yield* Effect.promise(() => - launcher.run().then( + main().then( () => Promise.reject(new Error("launcher unexpectedly completed")), () => Promise.resolve(), ), @@ -354,85 +368,20 @@ if (context.update?.status === "pending") { state.update?.status === "rolled-back" ? state.update.reason : undefined, "invalid-prepared", ); + const observations = (yield* fs.readFileString(observationsPath)) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.deepEqual(observations, [ + { token: "initial", update: "active" }, + { token: "initial", update: "pending" }, + { token: "initial", update: "rolled-back" }, + ]); + assert.equal(process.env.T3CODE_TEST_SERVICE_TOKEN, "inherited"); + assert.equal(yield* fs.readFileString(envPath), "T3CODE_TEST_SERVICE_TOKEN=changed\n"); }), ); - it.effect.each([ - { prepares: true, status: "committed", activeVersion: "1.1.0" }, - { prepares: false, status: "rolled-back", activeVersion: "1.0.0" }, - ] as const)( - "gives a trial the full prepared window (prepares: $prepares)", - ({ prepares, status, activeVersion }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-slow-" }); - const statePath = path.join(root, "runtime", "service-state.json"); - const databasePath = path.join(root, "userdata", "state.sqlite"); - yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); - yield* fs.writeFileString(databasePath, "before trial"); - const childSource = ` -const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); -if (context.update?.status !== "pending") process.exit(0); -process.on("message", (message) => { - if (message.type === "test-prepare") process.send({ type: "prepared", updateId: context.update.id }); - if (message.type === "committed") process.exit(0); -}); -setInterval(() => {}, 1_000); -`; - for (const version of ["1.0.0", "1.1.0"]) { - yield* writeFakeRuntime( - fs, - path, - path.join(root, "runtime", "versions", version), - childSource, - ); - } - yield* Effect.promise(() => - writeServiceState(statePath, { - protocol: SERVICE_LAUNCHER_PROTOCOL, - activeVersion: "1.0.0", - update: { - id: "update-1", - fromVersion: "1.0.0", - targetVersion: "1.1.0", - dbPath: databasePath, - status: "pending", - }, - }), - ); - - // Effect schedules on setImmediate, so faking only timeouts leaves it running. - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - yield* Effect.addFinalizer(() => Effect.sync(() => vi.useRealTimers())); - const launcher = new Launcher( - root, - yield* Effect.promise(() => readServiceState(statePath)), - ); - const running = launcher.run().then( - () => Promise.reject(new Error("launcher unexpectedly completed")), - () => Promise.resolve(), - ); - yield* Effect.promise(() => - vi.waitFor(() => assert.equal(vi.getTimerCount(), 1), { timeout: 10_000 }), - ); - yield* Effect.promise(() => vi.advanceTimersByTimeAsync(121_000)); - if (prepares) spawnedChildren.at(-1)!.send({ type: "test-prepare" }); - else yield* Effect.promise(() => vi.advanceTimersByTimeAsync(PREPARED_TIMEOUT_MS)); - yield* Effect.promise(() => running); - - const state = yield* Effect.promise(() => readServiceState(statePath)); - assert.equal(state.activeVersion, activeVersion); - assert.equal(state.update?.status, status); - if (!prepares) { - assert.equal( - state.update?.status === "rolled-back" ? state.update.reason : undefined, - "prepared-timeout", - ); - } - }), - ); - it.effect.each(["file", "symlink", "dangling symlink"] as const)( "restores the database when a migrating trial exits, with the database a %s", (kind) => @@ -693,41 +642,4 @@ setInterval(() => {}, 1_000); assert.include(blocker ?? "", "The server was not stopped"); }), ); - - it.effect("fails the backup check when the database cannot be inspected", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-blocker-" }); - const error = yield* Effect.promise(() => - databaseBackupBlocker(root, path.join(root, "missing.sqlite"), { - readFreeBytes: () => Promise.resolve(0), - }).then( - () => undefined, - (cause: unknown) => cause, - ), - ); - assert.equal((error as NodeJS.ErrnoException | undefined)?.code, "ENOENT"); - }), - ); - - it.effect("recognizes a clone into the backup directory on this filesystem", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-clone-" }); - const databasePath = path.join(root, "userdata", "state.sqlite"); - yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); - yield* fs.writeFileString(databasePath, "database"); - const blocker = yield* Effect.promise(() => - databaseBackupBlocker(root, databasePath, { readFreeBytes: () => Promise.resolve(0) }), - ); - assert.include( - blocker ?? "", - // oxlint-disable-next-line t3code/no-global-process-runtime -- mirrors the standalone launcher's own platform check. - process.platform === "darwin" ? "1.0 GB needed, 0.0 GB free" : "GB needed, 0.0 GB free", - ); - assert.deepEqual(yield* fs.readDirectory(path.join(root, "runtime", "db-backup")), []); - }), - ); }); diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index 86470023dcb1..9926159be285 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -9,6 +9,7 @@ import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; import type { PendingServiceUpdate, @@ -23,6 +24,7 @@ import { decodeServiceLauncherChildMessage, isExactServiceVersion, parseServiceState, + SERVICE_ENV_FILE, SERVICE_LAUNCHER_CONTEXT_ENV, SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, @@ -35,7 +37,7 @@ const HANDOFF_DELAY_MS = 2_000; * Migrations count toward it and can scale with the database: tens of GB on a cold page cache * take minutes. A missed window rolls back the database, so the next attempt pays the same cost. */ -export const PREPARED_TIMEOUT_MS = 30 * 60_000; +const PREPARED_TIMEOUT_MS = 30 * 60_000; const TERMINATE_GRACE_MS = 5_000; // Matches SERVER_EXIT_CODE_STATE_DIR_OWNED in contracts. The standalone // launcher stays independent of the Effect-backed contracts package. @@ -85,6 +87,36 @@ async function pathExists(target: string): Promise { } } +const MANAGED_SERVICE_ENV_NAMES = new Set([ + "PATH", + "T3CODE_HOME", + "T3_BOOT_SERVICE_UNIT", + SERVICE_LAUNCHER_CONTEXT_ENV, +]); + +export async function readServiceEnvironment( + baseDir: string, + inherited: NodeJS.ProcessEnv = process.env, +): Promise { + const environment = { ...inherited }; + let contents: string; + try { + contents = await NodeFSP.readFile(NodePath.join(baseDir, SERVICE_ENV_FILE), "utf8"); + } catch (cause) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return environment; + throw cause; + } + for (const [name, value] of Object.entries(NodeUtil.parseEnv(contents))) { + if ( + /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && + !MANAGED_SERVICE_ENV_NAMES.has(name.toUpperCase()) + ) { + environment[name] = value; + } + } + return environment; +} + // Opened read-write: Windows refuses to flush a handle without write access. async function syncFile(filePath: string): Promise { const handle = await NodeFSP.open(filePath, "r+"); @@ -443,14 +475,19 @@ export class Launcher { #done = false; readonly #completion = Promise.withResolvers(); readonly #backupSpaceProbe: Partial | undefined; + readonly #environment: Readonly; constructor( baseDir: string, state: ServiceState, - options: { readonly backupSpaceProbe?: Partial } = {}, + options: { + readonly backupSpaceProbe?: Partial; + readonly environment?: Readonly; + } = {}, ) { this.#baseDir = baseDir; this.#backupSpaceProbe = options.backupSpaceProbe; + this.#environment = { ...(options.environment ?? process.env) }; this.#statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE); this.#state = state; } @@ -581,7 +618,7 @@ export class Launcher { }; const spawnArguments = runtimeSpawnArguments(paths); const child = NodeChildProcess.spawn(spawnArguments.command, spawnArguments.args, { - env: { ...process.env, [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) }, + env: { ...this.#environment, [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) }, stdio: ["inherit", "inherit", "inherit", "ipc"], }); await new Promise((resolve, reject) => { @@ -829,7 +866,8 @@ export async function main(): Promise { if (baseDir === undefined || baseDir === "") { throw new Error("T3CODE_HOME is required by the T3 Code service launcher."); } + const environment = await readServiceEnvironment(baseDir); const statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE); const state = await readServiceState(statePath); - await new Launcher(baseDir, state).run(); + await new Launcher(baseDir, state, { environment }).run(); } diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 82ef745ff00b..b80ccabc0360 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -2,6 +2,7 @@ import { AntigravitySettings, ClaudeSettings, CodexSettings, + EasDeviceHostConfig, type ServerSettings, UsageLimitSourceConfig, secretSettingsFields, @@ -31,6 +32,7 @@ const providerSecretFields = new Map([ ), ]); const usageLimitSecretFields = secretSettingsFields(UsageLimitSourceConfig); +const easDeviceHostSecretFields = secretSettingsFields(EasDeviceHostConfig); const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url"); export interface SettingsSecret { @@ -142,5 +144,16 @@ export function mapSettingsSecrets( ]), ) as ServerSettings["usageLimitSources"]; - return { ...settings, providerInstances, sourceControlHosts, usageLimitSources }; + const easDeviceHosts = settings.easDeviceHosts.map( + (host) => + mapFields( + host, + easDeviceHostSecretFields, + (field, serverHost) => + `device-host-${encode(host.id)}-${encode(field)}` + + (serverHost === null ? "" : `-${encode(serverHost)}`), + ) as EasDeviceHostConfig, + ); + + return { ...settings, providerInstances, sourceControlHosts, usageLimitSources, easDeviceHosts }; } diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 5a2c1eaceee0..38a5f112c382 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -4,15 +4,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ConfigProvider from "effect/ConfigProvider"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; -import { GitCommandError } from "@t3tools/contracts"; import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -254,68 +251,6 @@ it.effect("lists pull requests with Bitbucket state and source branch query para }).pipe(Effect.provide(layer)); }); -it.effect("lists closed pull requests with both closed Bitbucket states", () => { - const { execute, layer } = makeLayer({ - response: () => - Response.json({ - values: [], - }), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - yield* bitbucket.listPullRequests({ - cwd: "/repo", - headSelector: "feature/closed", - state: "closed", - limit: 10, - }); - - assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ - ["pagelen", "10"], - ["sort", "-updated_on"], - [ - "q", - 'source.branch.name = "feature/closed" AND (state = "DECLINED" OR state = "SUPERSEDED")', - ], - ["state", "DECLINED"], - ["state", "SUPERSEDED"], - ]); - }).pipe(Effect.provide(layer)); -}); - -it.effect("expands all-state pull request listing instead of relying on Bitbucket defaults", () => { - const { execute, layer } = makeLayer({ - response: () => - Response.json({ - values: [], - }), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - yield* bitbucket.listPullRequests({ - cwd: "/repo", - headSelector: "feature/all", - state: "all", - limit: 10, - }); - - assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ - ["pagelen", "10"], - ["sort", "-updated_on"], - [ - "q", - 'source.branch.name = "feature/all" AND (state = "OPEN" OR state = "MERGED" OR state = "DECLINED" OR state = "SUPERSEDED")', - ], - ["state", "OPEN"], - ["state", "MERGED"], - ["state", "DECLINED"], - ["state", "SUPERSEDED"], - ]); - }).pipe(Effect.provide(layer)); -}); - it.effect("reads repository clone URLs and default branch", () => { const { layer } = makeLayer({ response: (request) => @@ -428,27 +363,6 @@ it.effect("prefers an explicit repository and uses context when the repository i }).pipe(Effect.provide(layer)); }); -it.effect("discovers the repository from cwd remotes when the repository is omitted", () => { - const { execute, resolve, listRemotes, layer } = makeLayer({ - response: (request) => - Response.json(request.url.endsWith("/branching-model") ? {} : repositoryJson), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const branch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); - - assert.strictEqual(branch, "main"); - assert.deepStrictEqual(resolve.mock.calls, [[{ cwd: "/repo" }]]); - assert.deepStrictEqual(listRemotes.mock.calls, [["/repo"]]); - assert.isTrue( - execute.mock.calls.every(([request]) => - request.url.startsWith("https://api.test.local/2.0/repositories/pingdotgg/t3code"), - ), - ); - }).pipe(Effect.provide(layer)); -}); - it.effect( "prefers the Bitbucket branching model development branch as the default PR target", () => { @@ -510,57 +424,6 @@ it.effect( }, ); -it.effect( - "falls back to the repository main branch when the Bitbucket branching model is unavailable", - () => { - const { layer } = makeLayer({ - response: (request) => - request.url.endsWith("/branching-model") - ? Response.json({ error: { message: "Not found" } }, { status: 404 }) - : Response.json(repositoryJson), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); - - assert.strictEqual(defaultBranch, "main"); - }).pipe(Effect.provide(layer)); - }, -); - -it.effect("creates repositories through the Bitbucket REST API", () => { - const { execute, layer } = makeLayer({ - response: () => Response.json(repositoryJson), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const cloneUrls = yield* bitbucket.createRepository({ - cwd: "/repo", - repository: "pingdotgg/t3code", - visibility: "private", - }); - - assert.deepStrictEqual(cloneUrls, { - nameWithOwner: "pingdotgg/t3code", - url: "https://bitbucket.org/pingdotgg/t3code.git", - sshUrl: "git@bitbucket.org:pingdotgg/t3code.git", - }); - - const request = execute.mock.calls[0]?.[0]; - assert.strictEqual(request?.url, "https://api.test.local/2.0/repositories/pingdotgg/t3code"); - assert.strictEqual(request?.method, "POST"); - assert.ok(request); - const rawBody = (request.body as { readonly body?: Uint8Array }).body; - assert.ok(rawBody); - assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), { - scm: "git", - is_private: true, - }); - }).pipe(Effect.provide(layer)); -}); - it.effect("creates pull requests using the official REST payload shape", () => { const { execute, layer } = makeLayer({ response: () => Response.json(bitbucketPullRequest), @@ -603,24 +466,6 @@ it.effect("creates pull requests using the official REST payload shape", () => { }).pipe(Effect.provide(layer), Effect.scoped); }); -it.effect("reports auth status through the Bitbucket REST /user endpoint", () => { - const { layer } = makeLayer({ - response: () => Response.json({ username: "bitbucket-user" }), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const auth = yield* bitbucket.probeAuth; - - assert.deepStrictEqual(auth, { - status: "authenticated", - account: Option.some("bitbucket-user"), - host: Option.some("bitbucket.org"), - detail: Option.none(), - }); - }).pipe(Effect.provide(layer)); -}); - it.effect("prefers credentials saved in settings over the environment, without a restart", () => { const { execute, layer } = makeLayer({ response: () => Response.json({ username: "bitbucket-user" }), @@ -703,42 +548,6 @@ it.effect("reports saved credentials as configured when Bitbucket cannot confirm }).pipe(Effect.provide(layer)); }); -it.effect("preserves the HTTP client failure without deriving the domain message from it", () => { - const transportCause = new Error("socket reset by peer"); - let requestFailure: HttpClientError.HttpClientError | undefined; - const { layer } = makeLayer({ - response: () => Response.json({}), - requestFailure: (request) => { - requestFailure = new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request, - cause: transportCause, - }), - }); - return requestFailure; - }, - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* Effect.flip( - bitbucket.getPullRequest({ - cwd: "/repo", - reference: "42", - }), - ); - - assert.instanceOf(error, BitbucketApi.BitbucketRequestError); - assert.strictEqual(error.operation, "getPullRequest"); - assert.strictEqual( - error.message, - "Bitbucket API failed in getPullRequest: Failed to send the Bitbucket request.", - ); - assert.strictEqual(error.cause, requestFailure); - assert.strictEqual(requestFailure?.cause, transportCause); - }).pipe(Effect.provide(layer)); -}); - it.effect("keeps Bitbucket response bodies out of checkout diagnostics", () => { const responseBody = '{"error":{"message":"credential=secret-value"}}'; const { layer } = makeLayer({ @@ -764,175 +573,6 @@ it.effect("keeps Bitbucket response bodies out of checkout diagnostics", () => { }).pipe(Effect.provide(layer)); }); -it.effect("keeps a 429 Retry-After time on the response error", () => { - const { layer } = makeLayer({ - response: () => new Response("busy", { status: 429, headers: { "Retry-After": "120" } }), - }); - - return Effect.gen(function* () { - yield* TestClock.setTime(1_000); - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* bitbucket - .request({ method: "GET", url: "/repositories/acme/web" }) - .pipe(Effect.flip); - - assert.instanceOf(error, BitbucketApi.BitbucketResponseError); - assert.strictEqual(error.status, 429); - assert.strictEqual(error.retryAt, 121_000); - }).pipe(Effect.provide(layer)); -}); - -it.effect("preserves Bitbucket response body read failures as their immediate cause", () => { - const cause = new Error("response stream failed"); - const { layer } = makeLayer({ - response: () => - new Response( - new ReadableStream({ - start: (controller) => controller.error(cause), - }), - { status: 502 }, - ), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* bitbucket - .getPullRequest({ cwd: "/repo", reference: "42" }) - .pipe(Effect.flip); - - assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError); - assert.strictEqual(error.operation, "getPullRequest"); - assert.strictEqual(error.status, 502); - assert.instanceOf(error.cause, HttpClientError.HttpClientError); - assert.strictEqual(error.cause.cause, cause); - assert.strictEqual( - error.message, - "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 502.", - ); - }).pipe(Effect.provide(layer)); -}); - -it.effect("keeps the 429 retry time when the response body cannot be read", () => { - const { layer } = makeLayer({ - response: () => - new Response( - new ReadableStream({ - start: (controller) => controller.error(new Error("response stream failed")), - }), - { status: 429, headers: { "Retry-After": "120" } }, - ), - }); - - return Effect.gen(function* () { - yield* TestClock.setTime(1_000); - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* bitbucket - .request({ method: "GET", url: "/repositories/acme/web" }) - .pipe(Effect.flip); - - assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError); - assert.strictEqual(error.status, 429); - assert.strictEqual(error.retryAt, 121_000); - }).pipe(Effect.provide(layer)); -}); - -it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { - const { git, layer } = makeLayer({ - response: () => - Response.json({ - ...bitbucketPullRequest, - source: { - branch: { name: "feature/source-control" }, - repository: { - full_name: "pingdotgg/t3code", - workspace: { slug: "pingdotgg" }, - }, - }, - }), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - yield* bitbucket.checkoutPullRequest({ - cwd: "/repo", - context: { - provider: { - kind: SourceControlProviderKind.make("bitbucket"), - name: "Bitbucket", - baseUrl: "https://bitbucket.org", - }, - remoteName: "origin", - remoteUrl: "git@bitbucket.org:pingdotgg/t3code.git", - }, - reference: "42", - force: true, - }); - - assert.strictEqual(git.ensureRemote.mock.calls.length, 0); - assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], { - cwd: "/repo", - remoteName: "origin", - remoteBranch: "feature/source-control", - localBranch: "feature/source-control", - }); - assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], { - cwd: "/repo", - branch: "feature/source-control", - remoteName: "origin", - remoteBranch: "feature/source-control", - }); - assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], { - cwd: "/repo", - refName: "feature/source-control", - }); - }).pipe(Effect.provide(layer)); -}); - -it.effect("preserves Git checkout failures without deriving the domain message from them", () => { - const gitCause = new GitCommandError({ - operation: "fetchRemoteBranch", - command: "git fetch origin feature/source-control", - cwd: "/repo", - detail: "remote rejected the request", - }); - const { layer } = makeLayer({ - response: () => - Response.json({ - ...bitbucketPullRequest, - source: { - branch: { name: "feature/source-control" }, - repository: { - full_name: "pingdotgg/t3code", - workspace: { slug: "pingdotgg" }, - }, - }, - }), - git: { - fetchRemoteBranch: () => Effect.fail(gitCause), - }, - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* Effect.flip( - bitbucket.checkoutPullRequest({ - cwd: "/repo", - reference: "42", - force: true, - }), - ); - - assert.instanceOf(error, BitbucketApi.BitbucketCheckoutError); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.reference, "42"); - assert.strictEqual( - error.message, - "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request.", - ); - assert.strictEqual(error.cause, gitCause); - }).pipe(Effect.provide(layer)); -}); - it.effect("checks out fork pull requests through an ensured fork remote", () => { const { git, layer } = makeLayer({ response: (request) => { @@ -1096,79 +736,3 @@ it.effect("cuts a response short rather than reading an unbounded diff into memo ), ), ); - -const stalledResponseBody = (status: number): Response => - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode("{")); - }, - }), - { status, headers: { "content-type": "application/json" } }, - ); - -it.effect.each([200, 503])( - "times out a stalled Bitbucket JSON body after HTTP %s headers", - (status) => { - const { execute, layer } = makeLayer({ response: () => stalledResponseBody(status) }); - return Effect.gen(function* () { - const api = yield* BitbucketApi.BitbucketApi; - const fiber = yield* Effect.forkChild( - Effect.flip(api.getPullRequest({ cwd: "/repo", reference: "42" })), - ); - while (execute.mock.calls.length === 0) yield* Effect.yieldNow; - yield* TestClock.adjust("31 seconds"); - const error = yield* Fiber.join(fiber); - assert.instanceOf(error, BitbucketApi.BitbucketRequestError); - assert.strictEqual(error.operation, "getPullRequest"); - }).pipe(Effect.provide(layer)); - }, -); - -it.effect.each([200, 503])( - "times out a stalled Bitbucket raw body after HTTP %s headers", - (status) => { - const { execute, layer } = makeLayer({ response: () => stalledResponseBody(status) }); - return Effect.gen(function* () { - const api = yield* BitbucketApi.BitbucketApi; - const fiber = yield* Effect.forkChild( - Effect.flip(api.request({ method: "GET", url: "/user" })), - ); - while (execute.mock.calls.length === 0) yield* Effect.yieldNow; - yield* TestClock.adjust("31 seconds"); - const error = yield* Fiber.join(fiber); - assert.instanceOf(error, BitbucketApi.BitbucketRequestError); - assert.strictEqual(error.operation, "request"); - }).pipe(Effect.provide(layer)); - }, -); - -it.effect("times out before JSON headers arrive", () => { - const { execute, layer } = makeLayer({ response: () => Response.json({}), stall: true }); - return Effect.gen(function* () { - const api = yield* BitbucketApi.BitbucketApi; - const fiber = yield* Effect.forkChild( - Effect.flip(api.getPullRequest({ cwd: "/repo", reference: "42" })), - ); - while (execute.mock.calls.length === 0) yield* Effect.yieldNow; - yield* TestClock.adjust("31 seconds"); - const error = yield* Fiber.join(fiber); - assert.instanceOf(error, BitbucketApi.BitbucketRequestError); - assert.strictEqual(error.operation, "getPullRequest"); - }).pipe(Effect.provide(layer)); -}); - -it.effect("times out before raw response headers arrive", () => { - const { execute, layer } = makeLayer({ response: () => Response.json({}), stall: true }); - return Effect.gen(function* () { - const api = yield* BitbucketApi.BitbucketApi; - const fiber = yield* Effect.forkChild( - Effect.flip(api.request({ method: "GET", url: "/user" })), - ); - while (execute.mock.calls.length === 0) yield* Effect.yieldNow; - yield* TestClock.adjust("31 seconds"); - const error = yield* Fiber.join(fiber); - assert.instanceOf(error, BitbucketApi.BitbucketRequestError); - assert.strictEqual(error.operation, "request"); - }).pipe(Effect.provide(layer)); -}); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 286afe81baea..42cce7ad85c8 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -2,14 +2,13 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; -import type * as Context from "effect/Context"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; -import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; +import { HttpClient, HttpClientResponse } from "effect/http"; import { VcsProcessSpawnError } from "@t3tools/contracts"; import * as ServerSettings from "../serverSettings.ts"; @@ -30,7 +29,6 @@ import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; -import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; @@ -99,138 +97,6 @@ const processOutput = ( }); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); -const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); - -it.effect("reports implemented tools separately from locally available executables", () => { - const processMock = { - run: (input: VcsProcess.VcsProcessInput) => { - if (input.command === "git") { - return Effect.succeed(processOutput("git version 2.51.0\n")); - } - if (input.command === "gh" && input.args[0] === "--version") { - return Effect.succeed(processOutput("gh version 2.83.0\n")); - } - if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { - return Effect.succeed( - processOutput( - encodeJson({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "juliusmarminge", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - }, - }), - ), - ); - } - return Effect.fail( - new VcsProcessSpawnError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - cause: new Error(`${input.command} not found`), - }), - ); - }, - } satisfies Partial; - const layerTest = SourceControlDiscovery.layer.pipe( - Layer.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-source-control-discovery-", - }), - ), - Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), - Layer.provide( - layerSourceControlProviderRegistryTest({ - process: processMock, - bitbucket: { - probeAuth: Effect.succeed({ - status: "unauthenticated", - account: Option.none(), - host: Option.some("bitbucket.org"), - detail: Option.some( - "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", - ), - }), - }, - }), - ), - Layer.provideMerge(NodeServices.layer), - ); - - return Effect.gen(function* () { - const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; - const result = yield* discovery.discover; - - assert.deepStrictEqual( - result.versionControlSystems.map((item) => ({ - kind: item.kind, - implemented: item.implemented, - status: item.status, - })), - [ - { kind: "git", implemented: true, status: "available" }, - { kind: "jj", implemented: false, status: "missing" }, - ], - ); - assert.deepStrictEqual( - result.sourceControlProviders.map((item) => ({ - kind: item.kind, - status: item.status, - auth: item.auth.status, - account: item.auth.account, - })), - [ - { - kind: SourceControlProviderKind.make("github"), - status: "available", - auth: "authenticated", - account: Option.some("juliusmarminge"), - }, - { - kind: SourceControlProviderKind.make("gitlab"), - status: "missing", - auth: "unknown", - account: Option.none(), - }, - { - kind: SourceControlProviderKind.make("azure-devops"), - status: "missing", - auth: "unknown", - account: Option.none(), - }, - { - kind: SourceControlProviderKind.make("bitbucket"), - status: "available", - auth: "unauthenticated", - account: Option.none(), - }, - { - kind: SourceControlProviderKind.make("forgejo"), - status: "missing", - auth: "unknown", - account: Option.none(), - }, - { - kind: SourceControlProviderKind.make("gitcafe"), - status: "missing", - auth: "unknown", - account: Option.none(), - }, - ], - ); - const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); - assert.ok(bitbucket); - assert.strictEqual(bitbucket.executable, undefined); - }).pipe(Effect.provide(layerTest)); -}); it.effect("probes provider authentication without exposing token details", () => { const processMock = { diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 6aa83b22b373..20f9cd0d033e 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -19,7 +19,6 @@ import { DEFAULT_SERVER_SETTINGS, VcsProcessSpawnError, VcsProcessTimeoutError, - VcsRepositoryDetectionError, } from "@t3tools/contracts"; import * as ServerSettings from "../serverSettings.ts"; @@ -168,97 +167,6 @@ function makeRegistry(input: { ); } -it.effect("routes GitHub remotes to the GitHub provider", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "git@github.com:pingdotgg/t3code.git" }], - }); - - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "github"); - }), -); - -it.effect("routes directly by provider kind for remote-first workflows", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [], - }); - - const provider = yield* registry.get(SourceControlProviderKind.make("github")); - - assert.strictEqual(provider.kind, "github"); - }), -); - -it.effect("includes the request cwd when an unregistered provider is used", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ remotes: [] }); - const provider = yield* registry.get(SourceControlProviderKind.make("unknown")); - - const error = yield* provider - .getChangeRequest({ cwd: "/repo", reference: "#42" }) - .pipe(Effect.flip); - - assert.strictEqual(error.provider, "unknown"); - assert.strictEqual(error.operation, "getChangeRequest"); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.reference, "#42"); - }), -); - -it.effect("lists no change requests for a repository on an unrecognized host", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "https://git.example.internal/team/repo.git" }], - }); - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "unknown"); - assert.deepStrictEqual( - yield* provider.listChangeRequests({ cwd: "/repo", headSelector: "master", state: "all" }), - [], - ); - }), -); - -it.effect("retains VCS detection failures with structured cwd context", () => - Effect.gen(function* () { - const cause = new VcsRepositoryDetectionError({ - operation: "resolve", - cwd: "/repo", - detail: "raw VCS detection failure", - cause: new Error("raw nested failure"), - }); - const registry = yield* makeRegistry({ - remotes: [], - resolve: () => Effect.fail(cause), - }); - - const error = yield* registry.resolve({ cwd: "/repo" }).pipe(Effect.flip); - - assert.strictEqual(error.provider, "unknown"); - assert.strictEqual(error.operation, "detectProvider"); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.detail, "Failed to detect source control provider."); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes(cause.message), false); - }), -); - -it.effect("routes GitLab remotes to the GitLab provider", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }], - }); - - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "gitlab"); - }), -); - it.effect("routes authenticated self-hosted GitLab remotes without relying on host naming", () => Effect.gen(function* () { const registry = yield* makeRegistry({ @@ -341,30 +249,6 @@ it.effect("routes authenticated self-hosted GitLab remotes on non-standard ports }), ); -it.effect("routes Bitbucket remotes to the Bitbucket provider", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "git@bitbucket.org:pingdotgg/t3code.git" }], - }); - - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "bitbucket"); - }), -); - -it.effect("routes Azure DevOps remotes to the Azure DevOps provider", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "https://dev.azure.com/acme/project/_git/repo" }], - }); - - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "azure-devops"); - }), -); - it.effect("falls back to a non-origin remote when origin is not configured", () => Effect.gen(function* () { const registry = yield* makeRegistry({ diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index cbce130dcc8f..14615a39856d 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -7,21 +7,20 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; -import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; -import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts"; +import { + GitCommandError, + SourceControlProviderError, + type SourceControlCloneRepositoryInput, +} from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; -import * as BitbucketSourceControlProvider from "@t3tools/source-control-bitbucket/server/BitbucketSourceControlProvider"; import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; -const isSourceControlProviderError = Schema.is(SourceControlProviderError); - const CLONE_URLS = { nameWithOwner: "octocat/t3code", url: "https://github.com/octocat/t3code", @@ -102,181 +101,14 @@ function layer(input: { : layerService.pipe(Layer.provideMerge(NodeServices.layer)); } -it.effect("looks up repositories through the requested provider without search", () => { - const calls: Array<{ cwd: string; repository: string }> = []; - const provider = makeProvider({ - getRepositoryCloneUrls: (input) => - Effect.sync(() => { - calls.push({ cwd: input.cwd, repository: input.repository }); - return CLONE_URLS; - }), - }); - - return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - const result = yield* service.lookupRepository({ - provider: SourceControlProviderKind.make("github"), - repository: "octocat/t3code", - cwd: "/workspace", - }); - - assert.deepStrictEqual(result, { - provider: SourceControlProviderKind.make("github"), - ...CLONE_URLS, - }); - assert.deepStrictEqual(calls, [{ cwd: "/workspace", repository: "octocat/t3code" }]); - }).pipe(Effect.provide(layer({ provider }))); -}); - -it.effect("preserves provider failures without deriving the repository message from them", () => { - const providerCause = new SourceControlProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getRepositoryCloneUrls", - cwd: "/workspace", - repository: "octocat/t3code", - detail: "credential token abc123 was rejected", - }); - const provider = makeProvider({ - getRepositoryCloneUrls: () => Effect.fail(providerCause), - }); - - return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - const error = yield* Effect.flip( - service.lookupRepository({ - provider: SourceControlProviderKind.make("github"), - repository: "octocat/t3code", - cwd: "/workspace", - }), - ); - - assert.strictEqual(error.provider, "github"); - assert.strictEqual(error.operation, "lookupRepository"); - assert.strictEqual(error.detail, "The source control operation could not be completed."); - assert.strictEqual( - error.message, - "Source control repository operation lookupRepository failed for github: The source control operation could not be completed.", - ); - assert.strictEqual(error.cause, providerCause); - }).pipe(Effect.provide(layer({ provider }))); -}); - -it.effect( - "reports the safe Bitbucket locator detail through repository lookup and publishing", - () => - Effect.gen(function* () { - const locatorCause = new BitbucketApi.BitbucketRepositoryLocatorError({ - repository: "credential-token-abc123", - }); - const provider = yield* BitbucketSourceControlProvider.make.pipe( - Effect.provide( - Layer.mock(BitbucketApi.BitbucketApi)({ - getRepositoryCloneUrls: () => Effect.fail(locatorCause), - createRepository: () => Effect.fail(locatorCause), - }), - ), - ); - - yield* Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - const input = { - provider: SourceControlProviderKind.make("bitbucket"), - repository: locatorCause.repository, - cwd: "/workspace", - }; - for (const operation of [ - service.lookupRepository(input).pipe(Effect.asVoid), - service.publishRepository({ ...input, visibility: "private" }).pipe(Effect.asVoid), - ]) { - const error = yield* Effect.flip(operation); - - assert.strictEqual(error.provider, "bitbucket"); - assert.strictEqual( - error.detail, - "Bitbucket repositories must be specified as workspace/repository.", - ); - assert.notInclude(error.message, locatorCause.repository); - assert.instanceOf(error.cause, SourceControlProviderError); - assert.strictEqual( - isSourceControlProviderError(error.cause) ? error.cause.cause : null, - locatorCause, - ); - } - }).pipe(Effect.provide(layer({ provider }))); - }), -); - -it.effect("keeps a plain Bitbucket locator lookalike out of the repository message", () => { - const provider = makeProvider({ - kind: SourceControlProviderKind.make("bitbucket"), - getRepositoryCloneUrls: () => - Effect.fail( - new SourceControlProviderError({ - provider: SourceControlProviderKind.make("bitbucket"), - operation: "getRepositoryCloneUrls", - cwd: "/workspace", - detail: "credential token abc123 was rejected", - cause: { - _tag: "BitbucketRepositoryLocatorError", - repository: "t3code", - detail: "credential token abc123 was rejected", - }, - }), - ), - }); - - return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - const error = yield* Effect.flip( - service.lookupRepository({ - provider: SourceControlProviderKind.make("bitbucket"), - repository: "t3code", - }), - ); - - assert.strictEqual(error.detail, "The source control operation could not be completed."); - assert.notInclude(error.message, "abc123"); - }).pipe(Effect.provide(layer({ provider }))); -}); - -it.effect("keeps arbitrary Bitbucket request failures out of the repository message", () => - Effect.gen(function* () { - const cause = new BitbucketApi.BitbucketRequestError({ - operation: "getRepository", - cause: new Error("credential token abc123 was rejected"), - }); - const provider = yield* BitbucketSourceControlProvider.make.pipe( - Effect.provide( - Layer.mock(BitbucketApi.BitbucketApi)({ - getRepositoryCloneUrls: () => Effect.fail(cause), - }), - ), - ); - - yield* Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - const error = yield* Effect.flip( - service.lookupRepository({ - provider: SourceControlProviderKind.make("bitbucket"), - repository: "pingdotgg/t3code", - cwd: "/workspace", - }), - ); - - assert.strictEqual(error.detail, "The source control operation could not be completed."); - assert.notInclude(error.message, "abc123"); - assert.notInclude(error.message, cause.detail); - }).pipe(Effect.provide(layer({ provider }))); - }), -); - -it.effect("clones a looked-up repository into the requested destination", () => +it.effect("clones a looked-up repository after creating missing parent directories", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const parent = yield* fs.makeTempDirectoryScoped({ + const temporaryDirectory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-clone-parent-", }); + const parent = path.join(temporaryDirectory, "missing", "nested"); const destinationPath = path.join(parent, "t3code"); const cloneCalls: Array<{ cwd: string; args: ReadonlyArray }> = []; @@ -305,7 +137,8 @@ it.effect("clones a looked-up repository into the requested destination", () => layer({ git: { execute: (input) => - Effect.sync(() => { + Effect.gen(function* () { + assert.isTrue(yield* fs.exists(parent).pipe(Effect.orDie)); cloneCalls.push({ cwd: input.cwd, args: input.args }); return processOutput(); }), @@ -413,6 +246,98 @@ it.effect("reports clone progress from git's stderr and keeps its error text on }).pipe(Effect.provide(NodeServices.layer)), ); +function rejectingSsh( + urls: Array, + httpsFails: boolean, + reason: GitCommandError["reason"] = "authentication_failed", +) { + return { + execute: (input: GitVcsDriver.ExecuteGitInput) => + Effect.gen(function* () { + const url = input.args.at(-2)!; + urls.push(url); + if (url.startsWith("https://") && !httpsFails) return processOutput(); + yield* ( + input.progress?.onStderrLine?.( + url.startsWith("https://") + ? "fatal: could not read Username for 'https://github.com': terminal prompts disabled" + : "git@github.com: Permission denied (publickey).", + ) ?? Effect.void + ); + return yield* new GitCommandError({ + operation: input.operation, + command: "git", + cwd: input.cwd, + reason, + detail: "Git command exited with a non-zero status.", + }); + }), + } satisfies Partial; +} + +const cloneInTemporaryDirectory = ( + git: Partial, + input: Omit, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-clone-fallback-" }) + .pipe(Effect.orDie); + return yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + return yield* service.cloneRepository({ + ...input, + destinationPath: path.join(parent, "t3code"), + }); + }).pipe(Effect.provide(layer({ git }).pipe(Layer.orDie))); + }).pipe(Effect.provide(NodeServices.layer)); + +it.effect("retries a clone over HTTPS when the SSH login is refused", () => + Effect.gen(function* () { + const urls: Array = []; + const result = yield* cloneInTemporaryDirectory(rejectingSsh(urls, false), { + remoteUrl: CLONE_URLS.sshUrl, + }); + + assert.deepStrictEqual(urls, [CLONE_URLS.sshUrl, "https://github.com/octocat/t3code.git"]); + assert.strictEqual(result.remoteUrl, "https://github.com/octocat/t3code.git"); + }), +); + +it.effect("reports both failures when the HTTPS retry fails too", () => + Effect.gen(function* () { + const urls: Array = []; + const error = yield* Effect.flip( + cloneInTemporaryDirectory(rejectingSsh(urls, true), { remoteUrl: CLONE_URLS.sshUrl }), + ); + + assert.strictEqual(urls.length, 2); + assert.strictEqual( + error.detail, + "git@github.com: Permission denied (publickey). Cloning over HTTPS instead also failed: fatal: could not read Username for 'https://github.com': terminal prompts disabled", + ); + }), +); + +it.effect.each([ + ["the caller asked for SSH", "ssh", "authentication_failed"], + ["git failed for another reason", undefined, "remote_unreachable"], +] as const)("does not retry over HTTPS when %s", ([, protocol, reason]) => + Effect.gen(function* () { + const urls: Array = []; + yield* Effect.flip( + cloneInTemporaryDirectory(rejectingSsh(urls, false, reason), { + remoteUrl: CLONE_URLS.sshUrl, + ...(protocol === undefined ? {} : { protocol }), + }), + ); + + assert.deepStrictEqual(urls, [CLONE_URLS.sshUrl]); + }), +); + it.effect("strips embedded credentials from the remote URL it reports", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -528,74 +453,6 @@ it.effect("preserves destination probe failures instead of treating them as miss ); }); -it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => { - const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = []; - const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = []; - const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; - const provider = makeProvider({ - createRepository: (input) => - Effect.sync(() => { - createCalls.push({ - cwd: input.cwd, - repository: input.repository, - visibility: input.visibility, - }); - return CLONE_URLS; - }), - }); - - return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - const result = yield* service.publishRepository({ - cwd: "/workspace", - provider: SourceControlProviderKind.make("github"), - repository: "octocat/t3code", - visibility: "private", - remoteName: "origin", - protocol: "ssh", - }); - - assert.deepStrictEqual(result, { - repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS }, - remoteName: "origin", - remoteUrl: CLONE_URLS.sshUrl, - branch: "feature/remote-v1", - upstreamBranch: "origin/feature/remote-v1", - status: "pushed", - }); - assert.deepStrictEqual(createCalls, [ - { cwd: "/workspace", repository: "octocat/t3code", visibility: "private" }, - ]); - assert.deepStrictEqual(remoteCalls, [ - { cwd: "/workspace", preferredName: "origin", url: CLONE_URLS.sshUrl }, - ]); - assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin" }]); - }).pipe( - Effect.provide( - layer({ - provider, - git: { - ensureRemote: (input) => - Effect.sync(() => { - remoteCalls.push(input); - return "origin"; - }), - pushCurrentBranch: (cwd, _fallbackBranch, options) => - Effect.sync(() => { - pushCalls.push({ cwd, remoteName: options?.remoteName }); - return { - status: "pushed" as const, - branch: "feature/remote-v1", - upstreamBranch: "origin/feature/remote-v1", - setUpstream: true, - }; - }), - }, - }), - ), - ); -}); - it.effect("publishes to the remote name returned by ensureRemote", () => { const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; @@ -670,6 +527,7 @@ it.effect("explains a rejected SSH push after creating the repository", () => }).pipe(Effect.provide(layer({ git: failingPush("authentication_failed") }))), ); +// regression: #19219 publishing did not explain why the first push failed it.effect("says the repository exists when an unclassified publish push fails", () => Effect.gen(function* () { const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index fb59349749a3..64fd0c9eec64 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -7,6 +7,7 @@ import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import { + GitCommandError, SourceControlProviderError, SourceControlRepositoryError, type GitCommandFailureReason, @@ -31,8 +32,10 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { cloneUrlForProtocol, cloneUrlProtocol } from "@t3tools/shared/cloneUrl"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); const isSourceControlProviderError = Schema.is(SourceControlProviderError); +const isGitCommandError = Schema.is(GitCommandError); const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError); export class SourceControlRepositoryService extends Context.Service< @@ -169,6 +172,27 @@ function publishPushFailureDetail(input: { } } +/** + * Adding a project clones over SSH for most hosts, and a machine with no SSH + * key there would fail every time, while the host's HTTPS credential helper + * may be set up. A clone whose SSH login was refused is retried once over + * HTTPS, unless the caller asked for SSH. A looked-up repository has the + * provider's own HTTPS URL; any other URL is rewritten, which is refused + * where it would have to guess. + */ +function httpsFallbackUrl( + input: SourceControlCloneRepositoryInput, + prepared: SourceControlPreparedClone, +): string | null { + if (input.protocol === "ssh" || cloneUrlProtocol(prepared.cloneUrl) !== "ssh") return null; + if (prepared.repository?.sshUrl === prepared.cloneUrl) return prepared.repository.url; + return cloneUrlForProtocol(prepared.cloneUrl, "https"); +} + +const isSshRejection = (error: SourceControlRepositoryError) => + isGitCommandError(error.cause) && + (error.cause.reason === "authentication_failed" || error.cause.reason === "host_key_unverified"); + function selectRemoteUrl( urls: SourceControlRepositoryCloneUrls, protocol: SourceControlCloneProtocol | undefined, @@ -262,7 +286,11 @@ export const make = Effect.gen(function* () { }); } } else { - yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true }); + // Skip existing parents: recursive mkdir on a Windows drive root fails with EPERM. + const parentPath = path.dirname(normalizedDestination); + if (!(yield* fileSystem.exists(parentPath))) { + yield* fileSystem.makeDirectory(parentPath, { recursive: true }); + } } return { @@ -308,11 +336,12 @@ export const make = Effect.gen(function* () { } satisfies SourceControlPreparedClone; }); - const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( + const runClone = Effect.fn("SourceControlRepositoryService.runClone")(function* ( input: SourceControlCloneRepositoryInput, + destinationPath: string, + cloneUrl: string, options?: SourceControlCloneOptions, ) { - const prepared = yield* prepareClone(input); const onProgress = options?.onProgress; // Git interleaves progress redraws with its real messages on stderr. The // last non-progress lines are what explain a failure ("Repository not @@ -332,14 +361,8 @@ export const make = Effect.gen(function* () { yield* git .execute({ operation: "SourceControlRepositoryService.cloneRepository", - cwd: path.dirname(prepared.destinationPath), - args: [ - "clone", - "--progress", - "--", - prepared.cloneUrl, - path.basename(prepared.destinationPath), - ], + cwd: path.dirname(destinationPath), + args: ["clone", "--progress", "--", cloneUrl, path.basename(destinationPath)], timeoutMs: options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs, // Progress redraws add up on a slow multi-GB clone. The buffered copy // is never read (the tail is kept by hand above), so keep it small @@ -364,10 +387,42 @@ export const make = Effect.gen(function* () { }), ), ); + }); + + const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( + input: SourceControlCloneRepositoryInput, + options?: SourceControlCloneOptions, + ) { + const prepared = yield* prepareClone(input); + const httpsUrl = httpsFallbackUrl(input, prepared); + const remoteUrl = yield* runClone( + input, + prepared.destinationPath, + prepared.cloneUrl, + options, + ).pipe( + Effect.as(prepared.remoteUrl), + Effect.catch((sshError) => + httpsUrl === null || !isSshRejection(sshError) + ? Effect.fail(sshError) + : runClone(input, prepared.destinationPath, httpsUrl, options).pipe( + Effect.as(redactRemoteUrl(httpsUrl)), + Effect.mapError( + (httpsError) => + new SourceControlRepositoryError({ + operation: "cloneRepository", + provider: sshError.provider, + detail: `${sshError.detail} Cloning over HTTPS instead also failed: ${httpsError.detail}`, + cause: httpsError.cause, + }), + ), + ), + ), + ); return { cwd: prepared.destinationPath, - remoteUrl: prepared.remoteUrl, + remoteUrl, repository: prepared.repository, }; }); diff --git a/apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts b/apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts index 1c7c34d2ab80..faba4ffdc5f1 100644 --- a/apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts +++ b/apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts @@ -12,15 +12,6 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubChangeRequestTemplate from "@t3tools/source-control-github/server/gitHubChangeRequestTemplate"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; -const SINGLE_TEMPLATE_PATHS = [ - ".github/pull_request_template.md", - ".github/PULL_REQUEST_TEMPLATE.md", - "pull_request_template.md", - "PULL_REQUEST_TEMPLATE.md", - "docs/pull_request_template.md", - "docs/PULL_REQUEST_TEMPLATE.md", -] as const; - const TEMPLATE_DIRECTORIES = [ ".github/PULL_REQUEST_TEMPLATE", "PULL_REQUEST_TEMPLATE", @@ -83,18 +74,6 @@ const detectTemplate = (cwd: string, treeish = "HEAD") => return yield* GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute); }); -it.effect.each(SINGLE_TEMPLATE_PATHS)("recognizes $0", (relativePath) => - runWithTempDirectory((cwd) => - Effect.gen(function* () { - yield* writeTemplate(cwd, relativePath, `template from ${relativePath}`); - yield* commitTemplates(cwd); - - const template = yield* detectTemplate(cwd); - assert.strictEqual(Option.getOrUndefined(template), `template from ${relativePath}`); - }), - ), -); - it.effect("reads templates from the requested base tree", () => runWithTempDirectory((cwd) => Effect.gen(function* () { diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 206940edac75..f9fc52750d23 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -1,4 +1,4 @@ -import { assert, expect, it } from "@effect/vitest"; +import { expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -10,13 +10,10 @@ import { ServerConfig, layerTest } from "./config.ts"; import { EnvironmentAuth } from "./auth/EnvironmentAuth.ts"; import { - buildPairingUrl, formatHeadlessServeOutput, issueHeadlessServeAccessInfo, renderTerminalQrCode, - resolveHeadlessConnectionHost, resolveHeadlessConnectionString, - resolveListeningPort, } from "./startupAccess.ts"; it.effect("prints the public URL and QR at startup with a loopback listener", () => @@ -53,16 +50,6 @@ it.effect("prints the public URL and QR at startup with a loopback listener", () ), ); -it("prefers localhost when no explicit host is configured", () => { - expect(resolveHeadlessConnectionHost(undefined)).toBe("localhost"); - expect(resolveHeadlessConnectionString(undefined, 3773)).toBe("http://localhost:3773"); -}); - -it("keeps explicit bind hosts in the connection string", () => { - expect(resolveHeadlessConnectionString("127.0.0.1", 3773)).toBe("http://127.0.0.1:3773"); - expect(resolveHeadlessConnectionString("::1", 3773)).toBe("http://[::1]:3773"); -}); - it("resolves wildcard hosts to a concrete external interface when one is available", () => { const connectionString = resolveHeadlessConnectionString("0.0.0.0", 3773, { en0: [ @@ -89,35 +76,3 @@ it("resolves wildcard hosts to a concrete external interface when one is availab expect(connectionString).toBe("http://192.168.1.42:3773"); }); - -it("prefers the actual bound port when an http server address is available", () => { - expect(resolveListeningPort({ port: 4123 }, 3773)).toBe(4123); - expect(resolveListeningPort("pipe", 3773)).toBe(3773); - expect(resolveListeningPort(null, 3773)).toBe(3773); -}); - -it("builds a pairing URL that embeds the token in the hash", () => { - expect(buildPairingUrl("http://192.168.1.42:3773", "PAIRCODE")).toBe( - "http://192.168.1.42:3773/pair#token=PAIRCODE", - ); -}); - -it("renders terminal QR codes as a multi-line unicode block grid", () => { - const qrCode = renderTerminalQrCode("http://192.168.1.42:3773/pair#token=PAIRCODE"); - - assert.isTrue(qrCode.includes("█")); - assert.isTrue(qrCode.split("\n").length > 10); -}); - -it("formats headless serve output with the connection string, token, pairing url, and qr code", () => { - const output = formatHeadlessServeOutput({ - connectionString: "http://192.168.1.42:3773", - token: "PAIRCODE", - pairingUrl: "http://192.168.1.42:3773/pair#token=PAIRCODE", - }); - - expect(output).toContain("Connection string: http://192.168.1.42:3773"); - expect(output).toContain("Token: PAIRCODE"); - expect(output).toContain("Pairing URL: http://192.168.1.42:3773/pair#token=PAIRCODE"); - assert.isTrue(output.includes("█") || output.includes("▀") || output.includes("▄")); -}); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index d86af1a4e542..34197a75c6ad 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -3,6 +3,7 @@ import * as NodeOS from "node:os"; import { QrCode } from "@t3tools/shared/qrCode"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/http"; +import * as NetAddress from "effect/net/NetAddress"; import * as ServerConfig from "./config.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -35,6 +36,11 @@ export const isWildcardHost = (host: string | undefined): boolean => export const formatHostForUrl = (host: string): string => host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +export const loopbackUrlHostForAddress = (address: NetAddress.InetAddress): string => + address._tag === "InetAddressV6" && NetAddress.isLoopback(address.address) + ? "[::1]" + : "127.0.0.1"; + const normalizeHost = (host: string): string => host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; @@ -42,7 +48,7 @@ const isIpv4Family = (family: string | number): boolean => family === "IPv4" || const isIpv6Family = (family: string | number): boolean => family === "IPv6" || family === 6; -export const resolveHeadlessConnectionHost = ( +const resolveHeadlessConnectionHost = ( host: string | undefined, interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), ): string => { @@ -77,7 +83,7 @@ export const resolveHeadlessConnectionString = ( return `http://${formatHostForUrl(connectionHost)}:${port}`; }; -export const resolveListeningPort = (address: unknown, fallbackPort: number): number => { +const resolveListeningPort = (address: unknown, fallbackPort: number): number => { if ( typeof address === "object" && address !== null && diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index e3870f8af0c4..992e219db912 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -5,16 +5,15 @@ import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import type * as Path from "effect/Path"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as Schema from "effect/Schema"; import { SCAN_CACHE_FILE_NAME } from "./usage/usageScanCache.ts"; import { TestClock } from "effect/testing"; import * as SqlClient from "effect/sql/SqlClient"; import { DEFAULT_SERVER_SETTINGS, - StorageCleanupReport, type WorktreeKeepWhen, type StorageCleanupSettings, } from "@t3tools/contracts"; @@ -33,16 +32,12 @@ import { CodexProviderCapabilitiesV2 } from "./orchestration-v2/Adapters/CodexAd import { ServerActivation } from "./serverActivation.ts"; import { describe, expect, it } from "@effect/vitest"; import { - EventId, ProjectId, - ProviderDriverKind, ProviderInstanceId, - ProviderSessionId, RunId, RuntimeRequestId, ThreadId, type OrchestrationV2DomainEvent, - type OrchestrationV2ProviderSession, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -54,7 +49,6 @@ import { const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); const DAY_MS = 24 * 60 * 60 * 1_000; -const decodeCleanupReport = Schema.decodeSync(StorageCleanupReport); function at(offsetMs: number): DateTime.Utc { return DateTime.makeUnsafe(NOW_MS + offsetMs); @@ -124,13 +118,6 @@ describe("V2 storage cleanup eligibility", () => { }, ); - it.each(["idle", "completed", "interrupted", "failed", "cancelled", "rolled_back"] as const)( - "allows cleanup once its thread is %s", - (status) => { - expect(storageCleanupThreadIdle(candidateWithStatus(status), NOW_MS)).toBe(true); - }, - ); - it.each(["completed", "interrupted", "cancelled", "rolled_back"] as const)( "retains %s while background work is pending", (status) => { @@ -236,6 +223,16 @@ describe("merged pull request cleanup", () => { }); }); +const allocatedBytes = (fs: FileSystem.FileSystem, file: string) => + fs.stat(file).pipe( + Effect.map((stat) => + Option.getOrElse( + Option.map(stat.blocks, (blocks) => Number(blocks) * 512), + () => Number(stat.size), + ), + ), + ); + const cleanupFixture = ( storageCleanup: StorageCleanupSettings = { ...DEFAULT_SERVER_SETTINGS.storageCleanup, @@ -247,7 +244,7 @@ const cleanupFixture = ( const fs = yield* FileSystem.FileSystem; const config = yield* ServerConfig.ServerConfig; const git = yield* GitVcsDriver.GitVcsDriver; - const repo = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" }); + const repo = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" })); const command = (cwd: string, args: string[]) => git.execute({ operation: "cleanup-test", cwd, args }); yield* command(repo, ["init", "-b", "main"]); @@ -279,7 +276,12 @@ const cleanupFixture = ( ...git, execute: (input) => Effect.gen(function* () { - if (editBeforeRemoval && input.args.includes("remove")) + // Before Git's own clean check: inside `git worktree remove`, or on + // Windows the same status check the removal makes before deleting. + if ( + editBeforeRemoval && + (input.args.includes("remove") || input.args.includes("--porcelain")) + ) yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n"); return yield* git.execute(input); }), @@ -306,14 +308,26 @@ const cleanupFixture = ( getShellSnapshot: (input?: { location?: string }) => Effect.sync(() => { snapshotReads++; - return { threads: input?.location === "archive" ? [] : threads }; + const visible = threads.filter((thread) => thread.deletedAt === null); + return { + threads: + input?.location === "archive" + ? [] + : visible.filter((thread) => thread.archivedAt === null), + archivedThreads: + input?.location === "active" + ? [] + : visible.filter((thread) => thread.archivedAt !== null), + }; }), - getThreadWorktreePaths: () => + getThreadWorktreePaths: (input?: { includeArchived?: boolean }) => Effect.sync(() => threads.flatMap((thread) => - thread.worktreePath === null - ? [] - : [{ threadId: thread.id, worktreePath: thread.worktreePath }], + thread.deletedAt === null && + (input?.includeArchived === true || thread.archivedAt === null) && + thread.worktreePath !== null + ? [{ threadId: thread.id, worktreePath: thread.worktreePath }] + : [], ), ), getThreadShell: (threadId: ThreadId) => @@ -443,27 +457,6 @@ const pausedMeasurementFixture = (storageCleanup?: StorageCleanupSettings) => measuredPath = yield* fs.realPath(fixture.worktree); return { ...fixture, measuring, resume }; }); -const sessionUpdated = ( - index: number, - status: OrchestrationV2ProviderSession["status"], -): OrchestrationV2DomainEvent => ({ - id: EventId.make(`event-${index}`), - type: "provider-session.updated", - threadId: ThreadId.make("thread-1"), - occurredAt: at(0), - payload: { - id: ProviderSessionId.make(`session-${index}`), - driver: ProviderDriverKind.make("codex"), - providerInstanceId: ProviderInstanceId.make("codex"), - status, - cwd: "/elsewhere", - model: null, - capabilities: CodexProviderCapabilitiesV2, - createdAt: at(0), - updatedAt: at(0), - lastError: null, - }, -}); const cleanupTestLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "storage-cleanup-" })), Layer.provideMerge(NodeServices.layer), @@ -480,6 +473,188 @@ const runCleanupTest = ( >, ) => effect.pipe(Effect.provide(cleanupTestLayer), Effect.scoped); +const submoduleCleanupFixture = () => + Effect.gen(function* () { + const fixture = yield* cleanupFixture(); + const { command, repo, worktree } = fixture; + const submodule = `${worktree}/module`; + const child = `${submodule}/child`; + for (const [cwd, name] of [ + [worktree, "module"], + [submodule, "child"], + ] as const) { + yield* command(cwd, ["-c", "protocol.file.allow=always", "submodule", "add", repo, name]); + yield* command(`${cwd}/${name}`, ["config", "user.name", "Cleanup test"]); + yield* command(`${cwd}/${name}`, ["config", "user.email", "cleanup@example.test"]); + } + const recordSubmodules = () => + Effect.gen(function* () { + yield* command(submodule, ["commit", "-am", "record child"]); + yield* command(submodule, ["push", "origin", "HEAD:submodule"]); + yield* command(worktree, ["commit", "-am", "record module"]); + }); + yield* recordSubmodules(); + return { ...fixture, submodule, child, recordSubmodules }; + }); + +it.live("removes clean submodules recursively, including deinitialized repositories", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, submodule, command, recordSubmodules } = + yield* submoduleCleanupFixture(); + yield* command(submodule, [ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + repo, + "stored", + ]); + yield* recordSubmodules(); + yield* command(submodule, ["submodule", "deinit", "stored"]); + const admin = (yield* command(worktree, ["rev-parse", "--absolute-git-dir"])).stdout.trim(); + const report = yield* service.runNow; + expect(report.entries.find((entry) => entry.path === worktree)).toMatchObject({ + outcome: "removed", + }); + expect(report.counts).toEqual({ removed: 1, kept: 0, failed: 0 }); + expect(yield* fs.exists(worktree)).toBe(false); + expect(yield* fs.exists(admin)).toBe(false); + }), + ), +); + +it.live("keeps nested submodule files protected by the current keep rule", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, submodule, child, command, setPolicy } = + yield* submoduleCleanupFixture(); + setPolicy("tracked-changes"); + yield* fs.writeFileString(`${child}/tracked.txt`, "local edit\n"); + expect( + (yield* service.runNow).entries.find((entry) => entry.path === worktree), + ).toMatchObject({ outcome: "kept" }); + expect(yield* fs.readFileString(`${child}/tracked.txt`)).toBe("local edit\n"); + yield* fs.writeFileString(`${child}/tracked.txt`, "original\n"); + setPolicy("any-local-files"); + yield* fs.writeFileString(`${child}/.env`, "local secret\n"); + expect( + (yield* service.runNow).entries.find((entry) => entry.path === worktree), + ).toMatchObject({ outcome: "kept" }); + expect(yield* fs.readFileString(`${child}/.env`)).toBe("local secret\n"); + + yield* fs.remove(`${child}/.env`); + yield* command(submodule, ["submodule", "deinit", "child"]); + yield* fs.writeFileString(`${child}/.env`, "secret after deinit\n"); + const report = yield* service.runNow; + expect({ + outcome: report.entries.find((entry) => entry.path === worktree)?.outcome, + contents: (yield* fs.exists(`${child}/.env`)) + ? yield* fs.readFileString(`${child}/.env`) + : null, + }).toEqual({ outcome: "kept", contents: "secret after deinit\n" }); + + yield* fs.remove(`${child}/.env`); + setPolicy("uncommitted-changes"); + yield* fs.writeFileString(`${child}/notes.txt`, "notes after deinit\n"); + const untrackedReport = yield* service.runNow; + expect({ + outcome: untrackedReport.entries.find((entry) => entry.path === worktree)?.outcome, + contents: (yield* fs.exists(`${child}/notes.txt`)) + ? yield* fs.readFileString(`${child}/notes.txt`) + : null, + }).toEqual({ outcome: "kept", contents: "notes after deinit\n" }); + }), + ), +); + +it.live("keeps unpushed submodule history after checkout and deinitialization", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, submodule, child, command, recordSubmodules } = + yield* submoduleCleanupFixture(); + yield* command(child, ["checkout", "-b", "local-only"]); + yield* fs.writeFileString(`${child}/tracked.txt`, "unpublished work\n"); + yield* command(child, ["commit", "-am", "local work"]); + const local = (yield* command(child, ["rev-parse", "HEAD"])).stdout.trim(); + yield* recordSubmodules(); + expect( + (yield* service.runNow).entries.find((entry) => entry.path === worktree), + ).toMatchObject({ outcome: "kept", reason: expect.stringContaining("remote-tracking") }); + expect(yield* fs.readFileString(`${child}/tracked.txt`)).toBe("unpublished work\n"); + + yield* command(child, ["checkout", "main"]); + yield* recordSubmodules(); + expect( + (yield* service.runNow).entries.find((entry) => entry.path === worktree), + ).toMatchObject({ outcome: "kept", reason: expect.stringContaining("remote-tracking") }); + const childGit = (yield* command(child, ["rev-parse", "--absolute-git-dir"])).stdout.trim(); + yield* command(submodule, ["submodule", "deinit", "child"]); + expect( + (yield* service.runNow).entries.find((entry) => entry.path === worktree), + ).toMatchObject({ outcome: "kept", reason: expect.stringContaining("remote-tracking") }); + expect( + (yield* command(worktree, [ + "--git-dir", + childGit, + "rev-parse", + "refs/heads/local-only", + ])).stdout.trim(), + ).toBe(local); + expect( + (yield* command(worktree, ["--git-dir", childGit, "show", "local-only:tracked.txt"])) + .stdout, + ).toBe("unpublished work\n"); + yield* command(worktree, ["--git-dir", childGit, "branch", "-D", "local-only"]); + expect( + (yield* service.runNow).entries.find((entry) => entry.path === worktree), + ).toMatchObject({ outcome: "kept", reason: expect.stringContaining("remote-tracking") }); + expect( + (yield* command(worktree, ["--git-dir", childGit, "show", `${local}:tracked.txt`])).stdout, + ).toBe("unpublished work\n"); + }), + ), +); + +it.live("keeps submodules when stored repository metadata is malformed", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, submodule, child, command } = yield* submoduleCleanupFixture(); + const childGit = (yield* command(child, ["rev-parse", "--absolute-git-dir"])).stdout.trim(); + yield* command(submodule, ["submodule", "deinit", "child"]); + yield* fs.writeFileString(`${childGit}/HEAD`, "not a ref\n"); + expect( + (yield* service.runNow).entries.find((entry) => entry.path === worktree), + ).toMatchObject({ outcome: "kept", reason: expect.stringContaining("inspected") }); + expect(yield* fs.exists(worktree)).toBe(true); + expect(yield* fs.readFileString(`${childGit}/HEAD`)).toBe("not a ref\n"); + }), + ), +); + +it.live("preserves late tracked edits when submodules were never initialized", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture(); + const { service, fs, worktree, command } = fixture; + const head = (yield* command(worktree, ["rev-parse", "HEAD"])).stdout.trim(); + yield* fs.writeFileString( + `${worktree}/.gitmodules`, + '[submodule "module"]\n\tpath = module\n\turl = /unused\n', + ); + yield* command(worktree, ["add", ".gitmodules"]); + yield* command(worktree, ["update-index", "--add", "--cacheinfo", `160000,${head},module`]); + yield* command(worktree, ["commit", "-m", "record uninitialized module"]); + yield* fs.makeDirectory(`${worktree}/module`); + fixture.editBeforeRemoval(); + expect( + (yield* service.runNow).entries.find((entry) => entry.path === worktree), + ).toMatchObject({ outcome: "failed" }); + expect(yield* fs.readFileString(`${worktree}/tracked.txt`)).toBe("late edit\n"); + }), + ), +); + describe("subagent worktree ownership", () => { it.effect.each([ "idle", @@ -740,27 +915,6 @@ describe("orphan worktree reports", () => { ), ); - it.live("waits an hour before reporting a folder without a thread row", () => - runCleanupTest( - Effect.gen(function* () { - const fixture = yield* cleanupFixture(); - fixture.setThreads([]); - const folder = `${fixture.config.worktreesDir}/review-guide-123/checkout`; - yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "review", folder]); - expect((yield* fixture.service.runNow).entries).toEqual([]); - yield* fixture.fs.utimes(folder, 1, 1); - expect((yield* fixture.service.runNow).entries).toEqual([ - expect.objectContaining({ - path: folder, - outcome: "kept", - reason: "No thread uses this worktree", - }), - ]); - expect(yield* fixture.fs.exists(`${folder}/tracked.txt`)).toBe(true); - }), - ), - ); - it.live("continues reporting when a project root cannot be probed", () => runCleanupTest( Effect.gen(function* () { @@ -779,73 +933,6 @@ describe("orphan worktree reports", () => { ), ); - it.effect.each(["walk-timeout", "sweep-budget"] as const)( - "caches walk timeouts and retries budget-cut orphan sizes (%s)", - (limit) => - runCleanupTest( - Effect.gen(function* () { - yield* TestClock.setTime(NOW_MS); - const fs = yield* FileSystem.FileSystem; - const measuring = yield* Deferred.make(); - let orphan = ""; - let walks = 0; - const fixture = yield* cleanupFixture().pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fs, - readDirectory: (directory, options) => - Effect.gen(function* () { - if (directory.endsWith("/repository") && walks === 0) - yield* TestClock.adjust("1 second"); - if (directory === orphan) { - walks++; - if (limit === "walk-timeout" || walks === 1) { - yield* Deferred.succeed(measuring, undefined); - return yield* Effect.never; - } - } - return (yield* fs.readDirectory(directory, options)).sort(); - }), - }), - ); - fixture.setThreads([]); - orphan = `${fixture.config.worktreesDir}/repository/orphan`; - yield* fs.makeDirectory(orphan, { recursive: true }); - yield* fs.writeFileString(`${orphan}/file`, "x"); - yield* fs.utimes(orphan, 1, 1); - const later = `${fixture.config.worktreesDir}/repository/z-later`; - yield* fs.makeDirectory(later); - yield* fs.utimes(later, 1, 1); - if (limit === "sweep-budget") { - const first = `${fixture.config.worktreesDir}/repository/a-first`; - yield* fs.makeDirectory(first); - yield* fs.utimes(first, 1, 1); - } - const orphanEntry = (report: StorageCleanupReport) => - report.entries.find((entry) => entry.path === orphan); - const sweep = yield* fixture.service.runNow.pipe(Effect.forkChild); - yield* Deferred.await(measuring); - yield* TestClock.adjust(limit === "walk-timeout" ? "30 seconds" : "29 seconds"); - const firstReport = yield* Fiber.join(sweep); - expect(orphanEntry(firstReport)?.bytes).toBeNull(); - expect(firstReport.entries.find((entry) => entry.path === later)?.bytes).toBeNull(); - const nextReport = yield* fixture.service.runNow; - expect(orphanEntry(nextReport)?.bytes).toBe(limit === "walk-timeout" ? null : 1); - expect(nextReport.entries.find((entry) => entry.path === later)?.bytes).toBe(0); - yield* fs.writeFileString(`${orphan}/file`, "changed"); - expect(orphanEntry(yield* fixture.service.runNow)?.bytes).toBe( - limit === "walk-timeout" ? null : 1, - ); - expect(walks).toBe(limit === "walk-timeout" ? 1 : 2); - if (limit === "walk-timeout") return; - fixture.setStoredWorktreePaths([orphan]); - expect(orphanEntry(yield* fixture.service.runNow)).toBeUndefined(); - fixture.setStoredWorktreePaths([]); - expect(orphanEntry(yield* fixture.service.runNow)?.bytes).toBe(7); - expect(walks).toBe(3); - }), - ), - ); - it.live.each(["nested", "direct"] as const)( "reports a linked orphan's size without removing its files (%s)", (location) => @@ -856,7 +943,7 @@ describe("orphan worktree reports", () => { const orphan = `${fixture.config.worktreesDir}/${location === "nested" ? "repository/" : ""}orphan`; yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "orphan", orphan]); const sizes = yield* Effect.forEach(yield* fixture.fs.readDirectory(orphan), (name) => - fixture.fs.stat(`${orphan}/${name}`).pipe(Effect.map((stat) => Number(stat.size))), + allocatedBytes(fixture.fs, `${orphan}/${name}`), ); const outside = yield* fixture.fs.makeTempDirectoryScoped(); yield* fixture.fs.writeFileString(`${outside}/external`, "outside data"); @@ -870,6 +957,7 @@ describe("orphan worktree reports", () => { outcome: "kept", reason: "No thread uses this worktree", path: orphan, + projectPath: null, threadId: null, threadTitle: null, bytes: sizes.reduce((sum, size) => sum + size, 0), @@ -965,96 +1053,6 @@ describe("orphan worktree reports", () => { ), ); - it.live.each(["direct", "symlink"] as const)( - "reports an orphan under a managed root inside a project (%s)", - (location) => - runCleanupTest( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const walked: string[] = []; - const fixture = yield* cleanupFixture().pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fs, - readDirectory: (directory, options) => { - walked.push(directory); - return fs.readDirectory(directory, options); - }, - }), - ); - fixture.setThreads([]); - const root = `${fixture.repo}/.worktrees`; - const orphan = `${root}/repository/orphan`; - const source = `${fixture.repo}/src`; - yield* fs.makeDirectory(`${source}/nested/deep`, { recursive: true }); - yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "orphan", orphan]); - if (location === "symlink") { - const alias = `${fixture.config.worktreesDir}/project-alias`; - yield* fs.symlink(fixture.repo, alias); - fixture.setProjectRoots([alias]); - } - fixture.setSettings({ worktreesDirectory: root }); - yield* fs.utimes(orphan, 1, 1); - const report = yield* fixture.service.runNow; - expect(report.entries).toEqual([ - expect.objectContaining({ - path: orphan, - outcome: "kept", - reason: "No thread uses this worktree", - }), - ]); - expect(walked.some((directory) => directory.startsWith(source))).toBe(false); - expect(yield* fs.readFileString(`${orphan}/tracked.txt`)).toBe("original\n"); - }), - ), - ); - - it.live("reports an orphan containing a removed project checkout", () => - runCleanupTest( - Effect.gen(function* () { - const fixture = yield* cleanupFixture(); - fixture.setThreads([]); - const orphan = `${fixture.config.worktreesDir}/repository/orphan`; - const removed = `${orphan}/removed-checkout`; - yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "orphan", orphan]); - yield* fixture.fs.makeDirectory(removed); - yield* fixture.fs.remove(removed, { recursive: true }); - fixture.setProjectRoots([fixture.repo, removed]); - yield* fixture.fs.utimes(orphan, 1, 1); - const report = yield* fixture.service.runNow; - expect(report.entries).toEqual([ - expect.objectContaining({ - path: orphan, - outcome: "kept", - reason: "No thread uses this worktree", - }), - ]); - expect(yield* fixture.fs.readFileString(`${orphan}/tracked.txt`)).toBe("original\n"); - }), - ), - ); - - it.live("skips project workspace internals while reporting a real orphan sibling", () => - runCleanupTest( - Effect.gen(function* () { - const fixture = yield* cleanupFixture(); - fixture.setThreads([]); - const project = `${fixture.config.worktreesDir}/project-checkout`; - const orphan = `${fixture.config.worktreesDir}/repository/orphan`; - yield* fixture.fs.makeDirectory(`${project}/src/nested`, { recursive: true }); - yield* fixture.fs.makeDirectory(orphan, { recursive: true }); - yield* fixture.fs.writeFileString(`${orphan}/file`, "x"); - fixture.setProjectRoots([fixture.repo, project]); - yield* fixture.fs.utimes(orphan, 1, 1); - const report = yield* fixture.service.runNow; - expect(report.entries).toEqual([ - expect.objectContaining({ path: orphan, outcome: "kept", bytes: 1 }), - ]); - expect(yield* fixture.fs.exists(`${project}/src/nested`)).toBe(true); - expect(yield* fixture.fs.exists(orphan)).toBe(true); - }), - ), - ); - it.live("skips symbolic links at both directory levels and disabled cleanup", () => runCleanupTest( Effect.gen(function* () { @@ -1091,7 +1089,7 @@ describe("orphan worktree reports", () => { expect.objectContaining({ path: folder, outcome: "kept", - bytes: 13, + bytes: yield* allocatedBytes(fixture.fs, `${folder}/payload`), reason: "No thread uses this folder, and it isn't a git worktree", }), ]); @@ -1119,7 +1117,11 @@ describe("orphan worktree reports", () => { Effect.ensuring(fixture.fs.chmod(blocked, 0o700).pipe(Effect.orDie)), ); expect(report.entries).toEqual([ - expect.objectContaining({ path: orphan, outcome: "kept", bytes: 1 }), + expect.objectContaining({ + path: orphan, + outcome: "kept", + bytes: yield* allocatedBytes(fixture.fs, `${orphan}/file`), + }), ]); expect(yield* fixture.fs.exists(orphan)).toBe(true); }), @@ -1152,30 +1154,44 @@ describe("orphan worktree reports", () => { }), ), ); +}); - it.live("caps orphan entries while preserving kept totals", () => +describe("storage cleanup reports and local file policies", () => { + it.effect("automatically removes an eligible archived thread checkout", () => runCleanupTest( Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); const fixture = yield* cleanupFixture(); - fixture.setThreads([]); - for (let i = 0; i < 205; i++) { - const orphan = `${fixture.config.worktreesDir}/repository/orphan-${i}`; - yield* fixture.fs.makeDirectory(orphan, { recursive: true }); - yield* fixture.fs.writeFileString(`${orphan}/file`, "x"); - yield* fixture.fs.utimes(orphan, 1, 1); - } - const report = yield* fixture.service.runNow; - expect(report.entries).toHaveLength(200); - expect(report.entries.every((entry) => entry.bytes === 1)).toBe(true); - expect(report.omittedCount).toBe(5); - expect(report.counts).toEqual({ removed: 0, kept: 205, failed: 0 }); - expect(report.bytesFreed).toBe(0); + fixture.setThreads([ + shell({ + branch: "feature", + worktreePath: fixture.worktree, + archivedAt: at(-DAY_MS), + }), + ]); + yield* fixture.activate(Stream.empty); + const [report] = yield* fixture.service.reports.pipe( + Stream.filter((report) => report !== null), + Stream.take(1), + Stream.runCollect, + ); + yield* fixture.service.drain; + expect(report).toMatchObject({ + trigger: "automatic", + counts: { removed: 1, kept: 0, failed: 0 }, + entries: [ + { + threadId: "thread-1", + path: fixture.worktree, + outcome: "removed", + reason: "Inactive for 30 days", + }, + ], + }); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); }), ), ); -}); - -describe("storage cleanup reports and local file policies", () => { it.live.each(["before-check", "while-measuring"] as const)( "keeps a worktree protected by a missing project root (%s)", (timing) => @@ -1202,26 +1218,114 @@ describe("storage cleanup reports and local file policies", () => { ), ); - it.live("measures nested regular files without following symlinks", () => + it.live("reports allocated bytes of single-link files without following symlinks", () => runCleanupTest( Effect.gen(function* () { const { service, fs, worktree, setPolicy } = yield* cleanupFixture(); setPolicy("tracked-changes"); const sizes = yield* Effect.forEach(yield* fs.readDirectory(worktree), (name) => - fs.stat(`${worktree}/${name}`).pipe(Effect.map((stat) => Number(stat.size))), + allocatedBytes(fs, `${worktree}/${name}`), ); const outside = yield* fs.makeTempDirectoryScoped(); yield* fs.writeFileString(`${outside}/external`, "must not count"); + yield* fs.writeFileString(`${outside}/store-file`, "shared ".repeat(10_000)); yield* fs.makeDirectory(`${worktree}/node_modules/nested`, { recursive: true }); yield* fs.writeFileString(`${worktree}/node_modules/nested/file`, "count me"); + sizes.push(yield* allocatedBytes(fs, `${worktree}/node_modules/nested/file`)); + yield* fs.link(`${outside}/store-file`, `${worktree}/node_modules/hardlinked`); yield* fs.symlink(outside, `${worktree}/node_modules/directory-link`); yield* fs.symlink(`${outside}/external`, `${worktree}/node_modules/file-link`); yield* fs.symlink(`${outside}/missing`, `${worktree}/node_modules/dangling-link`); const report = yield* service.runNow; - const bytes = sizes.reduce((sum, size) => sum + size, 8); + const bytes = sizes.reduce((sum, size) => sum + size, 0); expect(report.entries[0]).toMatchObject({ outcome: "removed", bytes, files: null }); expect(report.bytesFreed).toBe(bytes); expect(yield* fs.readFileString(`${outside}/external`)).toBe("must not count"); + expect(yield* fs.readFileString(`${outside}/store-file`)).toBe("shared ".repeat(10_000)); + expect(yield* fs.exists(worktree)).toBe(false); + }), + ), + ); + it.live.each(["same-device", "other-device", "no-manifest"] as const)( + "counts the pnpm virtual store only when its store forces copies (%s)", + (store) => + runCleanupTest( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const storeDir = yield* fs.makeTempDirectoryScoped({ prefix: "pnpm-store-" }); + const { service, worktree, setPolicy } = yield* cleanupFixture().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + stat: (target) => + fs + .stat(target) + .pipe( + Effect.map((info) => + store === "other-device" && target === storeDir + ? { ...info, dev: info.dev + 1n } + : info, + ), + ), + }), + ); + setPolicy("tracked-changes"); + const nodeModules = `${worktree}/node_modules`; + yield* fs.makeDirectory(`${nodeModules}/.pnpm/pkg@1.0.0`, { recursive: true }); + yield* fs.makeDirectory(`${nodeModules}/.vite`); + yield* fs.writeFileString( + `${nodeModules}/.pnpm/pkg@1.0.0/index.js`, + "clone ".repeat(10_000), + ); + yield* fs.writeFileString(`${nodeModules}/.vite/deps.js`, "cache"); + if (store !== "no-manifest") + yield* fs.writeFileString( + `${nodeModules}/.modules.yaml`, + JSON.stringify({ storeDir, virtualStoreDir: ".pnpm" }), + ); + const counted = [ + ...(yield* fs.readDirectory(worktree)).flatMap((name) => + name === "node_modules" ? [] : [`${worktree}/${name}`], + ), + `${nodeModules}/.vite/deps.js`, + ...(store === "no-manifest" ? [] : [`${nodeModules}/.modules.yaml`]), + ...(store === "same-device" ? [] : [`${nodeModules}/.pnpm/pkg@1.0.0/index.js`]), + ]; + const bytes = (yield* Effect.forEach(counted, (file) => allocatedBytes(fs, file))).reduce( + (sum, size) => sum + size, + 0, + ); + const report = yield* service.runNow; + expect(report.entries[0]).toMatchObject({ outcome: "removed", bytes }); + }), + ), + ); + // An agent can write this manifest. Statting its store sent the Windows + // user's NTLM credentials to attacker.test during a background sweep. + it.live.each([ + { storeDir: "\\\\attacker.test\\s" }, + { storeDir: "/local/store", virtualStoreDir: "//attacker.test/s" }, + ])("never stats a network pnpm store %o", (manifest) => + runCleanupTest( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const statted: Array = []; + const { service, worktree, setPolicy } = yield* cleanupFixture().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + stat: (target) => { + statted.push(target); + return fs.stat(target); + }, + }), + ); + setPolicy("tracked-changes"); + yield* fs.makeDirectory(`${worktree}/node_modules`); + yield* fs.writeFileString( + `${worktree}/node_modules/.modules.yaml`, + JSON.stringify(manifest), + ); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); + expect(statted.filter((target) => target.includes("attacker.test"))).toEqual([]); }), ), ); @@ -1264,6 +1368,18 @@ describe("storage cleanup reports and local file policies", () => { shell({ id: ThreadId.make("thread-2"), branch: "feature", worktreePath: worktree }), ], ], + [ + "an archived thread starts using it", + (worktree: string) => [ + shell({ branch: "feature", worktreePath: worktree }), + shell({ + id: ThreadId.make("thread-2"), + branch: "feature", + worktreePath: worktree, + archivedAt: at(-DAY_MS), + }), + ], + ], ] as const)("keeps a worktree when %s during size measurement", ([, threadsWhileMeasuring]) => runCleanupTest( Effect.gen(function* () { @@ -1287,41 +1403,6 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); - it.live("reads the thread snapshot once per sweep, not per worktree", () => - runCleanupTest( - Effect.gen(function* () { - const { service, snapshotReads } = yield* cleanupFixture(); - expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); - expect(snapshotReads()).toBe(1); - }), - ), - ); - it.live.each([ - ["running", "ready", 1], - ["stopped", "error", 2], - ] as const)( - "session updates to %s or %s during a sweep make %i sweep(s) in total", - ([first, second, sweeps]) => - runCleanupTest( - Effect.gen(function* () { - const fixture = yield* pausedMeasurementFixture(); - const handled = yield* Deferred.make(); - yield* fixture.activate( - Stream.fromEffect(Deferred.await(fixture.measuring)).pipe( - Stream.flatMap(() => Stream.range(0, 19)), - Stream.map((index) => sessionUpdated(index, index % 2 === 0 ? first : second)), - Stream.concat( - Stream.fromEffect(Deferred.succeed(handled, undefined)).pipe(Stream.drain), - ), - ), - ); - yield* Deferred.await(handled); - yield* Deferred.succeed(fixture.resume, undefined); - yield* fixture.service.drain; - expect(fixture.snapshotReads()).toBe(sweeps); - }), - ), - ); it.live("does not measure a worktree kept for an open provider session", () => runCleanupTest( Effect.gen(function* () { @@ -1356,7 +1437,7 @@ describe("storage cleanup reports and local file policies", () => { it.live("compares the checked-out branch without a full status", () => runCleanupTest( Effect.gen(function* () { - const { service, fs, worktree, command, setThreads, fullStatusCalls } = + const { service, fs, repo, worktree, command, setThreads, fullStatusCalls } = yield* cleanupFixture(); const changed = { outcome: "kept", reason: "Repository or branch changed" }; yield* command(worktree, ["checkout", "--detach"]); @@ -1374,8 +1455,15 @@ describe("storage cleanup reports and local file policies", () => { const index = (yield* command(worktree, ["rev-parse", "--git-path", "index"])).stdout; yield* fs.writeFileString(`${index.trim()}.lock`, ""); const locked = (yield* service.runNow).entries[0]; - expect(locked).toMatchObject({ outcome: "failed" }); + expect(locked).toMatchObject({ + outcome: "failed", + projectPath: repo, + path: worktree, + threadId: "thread-1", + threadTitle: "Thread", + }); expect(locked?.reason).toContain("Git index is locked"); + expect(yield* fs.readFileString(`${worktree}/tracked.txt`)).toBe("original\n"); yield* fs.remove(`${index.trim()}.lock`); const gitFile = yield* fs.readFileString(`${worktree}/.git`); yield* fs.writeFileString(`${worktree}/.git`, "gitdir: /missing-git-directory\n"); @@ -1386,27 +1474,6 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); - it("decodes reports from servers without size fields", () => { - const report = decodeCleanupReport({ - trigger: "manual", - startedAt: "2026-06-10T12:00:00.000Z", - finishedAt: "2026-06-10T12:00:01.000Z", - entries: [ - { - kind: "worktree", - outcome: "removed", - reason: "Removed", - path: null, - threadId: null, - threadTitle: null, - }, - ], - counts: { removed: 1, kept: 0, failed: 0 }, - omittedCount: 0, - }); - expect(report.bytesFreed).toBe(0); - expect(report.entries[0]).toMatchObject({ bytes: null, files: null }); - }); it.live.each([ ["any-local-files", ".env", "kept"], ["uncommitted-changes", ".env", "removed"], @@ -1425,7 +1492,7 @@ describe("storage cleanup reports and local file policies", () => { expect(yield* service.latestReport).toBeNull(); yield* fs.writeFileString(`${worktree}/${file}`, "local data\n"); const sizes = yield* Effect.forEach(yield* fs.readDirectory(worktree), (name) => - fs.stat(`${worktree}/${name}`).pipe(Effect.map((stat) => Number(stat.size))), + allocatedBytes(fs, `${worktree}/${name}`), ); const bytes = sizes.reduce((sum, size) => sum + size, 0); const report = yield* service.runNow; @@ -1451,6 +1518,83 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live( + "discovers trunk without a remote HEAD or tracking ref and removes an integrated worktree", + () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command } = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeUnchanged: true, + }); + const remote = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-remote-" }); + yield* command(remote, ["init", "--bare", "-b", "trunk"]); + yield* command(repo, ["remote", "add", "origin", remote]); + yield* command(repo, ["push", "origin", "main:trunk"]); + yield* command(repo, ["update-ref", "-d", "refs/remotes/origin/trunk"]); + expect((yield* command(repo, ["for-each-ref", "refs/remotes/origin"])).stdout).toBe(""); + + const report = yield* service.runNow; + expect(report.entries[0]).toMatchObject({ + outcome: "removed", + reason: "No commits beyond the default branch", + }); + expect(yield* fs.exists(worktree)).toBe(false); + }), + ), + ); + it.live( + "keeps worktree commits present on nondefault main but absent from advertised trunk", + () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command } = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeUnchanged: true, + }); + const remote = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-remote-" }); + yield* command(remote, ["init", "--bare", "-b", "trunk"]); + yield* command(repo, ["remote", "add", "origin", remote]); + yield* command(repo, ["push", "origin", "main:trunk"]); + yield* fs.writeFileString(`${worktree}/tracked.txt`, "unmerged work\n"); + yield* command(worktree, ["commit", "-am", "unmerged work"]); + yield* command(repo, ["push", "origin", "feature:main"]); + expect( + (yield* command(repo, ["symbolic-ref", "refs/remotes/origin/HEAD"]).pipe(Effect.exit)) + ._tag, + ).toBe("Failure"); + + const report = yield* service.runNow; + expect(report.entries[0]).toMatchObject({ + outcome: "kept", + reason: "Has commits beyond the default branch", + }); + expect(yield* fs.readFileString(`${worktree}/tracked.txt`)).toBe("unmerged work\n"); + }), + ), + ); + it.live("keeps the worktree when the remote default is unavailable even if main exists", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command } = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeUnchanged: true, + }); + const remote = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-remote-" }); + yield* command(remote, ["init", "--bare", "-b", "trunk"]); + yield* command(repo, ["remote", "add", "origin", remote]); + // The remote HEAD names an unborn trunk; main exists but is not its default. + yield* command(repo, ["push", "origin", "main"]); + + const report = yield* service.runNow; + expect(report.entries[0]).toMatchObject({ + outcome: "kept", + reason: "Default branch is unavailable", + }); + expect(yield* fs.exists(worktree)).toBe(true); + }), + ), + ); it.live("preserves tracked edits written after the final status check", () => runCleanupTest( Effect.gen(function* () { @@ -1469,21 +1613,6 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); - it.live("streams the current report and subsequent runs", () => - runCleanupTest( - Effect.gen(function* () { - const { service } = yield* cleanupFixture(); - const reports = yield* service.reports.pipe( - Stream.take(2), - Stream.tap((report) => (report === null ? service.runNow : Effect.void)), - Stream.runCollect, - ); - expect(reports[0]).toBeNull(); - expect(reports[1]).toEqual(yield* service.latestReport); - expect(reports[1]?.counts).toEqual({ removed: 1, kept: 0, failed: 0 }); - }), - ), - ); it.live("caps entries while preserving totals and prioritizing removals", () => runCleanupTest( Effect.gen(function* () { @@ -1528,29 +1657,6 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); - it.live( - "reports shared and running worktrees but omits disabled rules and missing directories", - () => - runCleanupTest( - Effect.gen(function* () { - const fixture = yield* cleanupFixture(); - const { service, fs, worktree, setThreads } = fixture; - setThreads([shell({ branch: "feature", worktreePath: worktree, status: "running" })]); - expect((yield* service.runNow).entries[0]?.reason).toContain("Thread is running"); - setThreads([ - shell({ branch: "feature", worktreePath: worktree }), - shell({ id: ThreadId.make("thread-2"), branch: "feature", worktreePath: worktree }), - ]); - expect((yield* service.runNow).entries[0]?.reason).toBe("Shared by 2 threads"); - fixture.disable(); - expect((yield* service.runNow).entries).toEqual([]); - yield* fs.remove(worktree, { recursive: true }); - expect( - (yield* service.runNow).entries.filter((entry) => entry.kind === "worktree"), - ).toEqual([]); - }), - ), - ); it.live("serializes concurrent requests and returns each run's report", () => runCleanupTest( Effect.gen(function* () { @@ -1628,102 +1734,6 @@ describe("storage cleanup reports and local file policies", () => { ); }); -describe("settled worktree retention", () => { - it.effect.each([ - "immediate", - "settle-script", - "expired", - "boundary", - "wait", - "recent-update", - "active", - "dirty", - "project-off", - "project-custom", - "inactive-settling", - ] as const)("applies retention and safety checks (%s)", (scenario) => - Effect.gen(function* () { - yield* TestClock.setTime(NOW_MS); - const fixture = yield* cleanupFixture(); - const days = ["wait", "boundary", "expired", "recent-update"].includes(scenario) ? 8 : 0; - const rules = { - ...DEFAULT_SERVER_SETTINGS.storageCleanup, - worktreeSettledAfterDays: - scenario === "project-custom" || scenario === "inactive-settling" ? null : days, - worktreeAfterDays: scenario === "inactive-settling" ? 1 : null, - }; - fixture.setSettings({ - storageCleanup: rules, - projectSettingsOverrides: { - [ProjectId.make("project-1")]: - scenario === "project-off" - ? { worktreeCleanup: { mode: "off" } } - : scenario === "project-custom" - ? { - worktreeCleanup: { - mode: "custom", - rules: { ...rules, worktreeSettledAfterDays: 0 }, - }, - } - : {}, - }, - }); - const thread = shell({ - branch: "feature", - worktreePath: fixture.worktree, - settledAt: - scenario === "active" - ? null - : at( - scenario === "expired" || - scenario === "recent-update" || - scenario === "inactive-settling" - ? -20 * DAY_MS - : scenario === "boundary" - ? -8 * DAY_MS - : scenario === "wait" - ? -8 * DAY_MS + 1 - : scenario === "settle-script" - ? 0 - : -60_000, - ), - updatedAt: at( - scenario === "recent-update" || - scenario === "settle-script" || - scenario === "inactive-settling" - ? 0 - : -10 * DAY_MS, - ), - }); - fixture.setThreads([thread]); - if (scenario === "dirty") - yield* fixture.fs.writeFileString(`${fixture.worktree}/tracked.txt`, "edit\n"); - const report = yield* fixture.service.runNow; - const removed = ["immediate", "expired", "boundary", "project-custom"].includes(scenario); - expect(yield* fixture.fs.exists(fixture.worktree)).toBe(!removed); - expect(report.counts.removed).toBe(removed ? 1 : 0); - if (scenario !== "project-off") { - const reason = - scenario === "active" - ? "Not settled" - : scenario === "wait" - ? "Settled for 7 of 8 days" - : ["settle-script", "recent-update", "inactive-settling"].includes(scenario) - ? "Settled moments ago" - : scenario === "dirty" - ? "Has uncommitted changes (1 file)" - : `Settled for ${scenario === "expired" ? 20 : scenario === "boundary" ? 8 : 0} days`; - expect(report.entries[0]).toMatchObject({ outcome: removed ? "removed" : "kept", reason }); - } - expect(thread.branch).toBe("feature"); - expect( - (yield* fixture.command(fixture.repo, ["rev-parse", "--verify", "refs/heads/feature"])) - .exitCode, - ).toBe(0); - }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), - ); -}); - describe("settled worktree rechecks", () => { it.live.each([{ settledAt: null }, { settledAt: at(DAY_MS) }])( "keeps a checkout changed during measurement (%j)", @@ -1776,52 +1786,3 @@ it.live("keeps saved usage data when removing a settled checkout", () => }), ), ); - -it.live.each(["thread.settled", "stopped", "ready"] as const)( - "leaves settled cleanup to the regular sweeps after %s", - (trigger) => - runCleanupTest( - Effect.gen(function* () { - const fixture = yield* pausedMeasurementFixture({ - ...DEFAULT_SERVER_SETTINGS.storageCleanup, - worktreeSettledAfterDays: 0, - }); - fixture.setSettings({ - storageCleanup: { - ...DEFAULT_SERVER_SETTINGS.storageCleanup, - worktreeSettledAfterDays: 0, - }, - }); - const thread = shell({ - branch: "feature", - worktreePath: fixture.worktree, - settledAt: at(0), - }); - fixture.setThreads([thread]); - const handled = yield* Deferred.make(); - yield* fixture.activate( - Stream.fromEffect(Deferred.await(fixture.measuring)).pipe( - Stream.flatMap(() => Stream.range(0, 19)), - Stream.map((index): OrchestrationV2DomainEvent => - trigger === "thread.settled" - ? { - id: EventId.make(`settled-${index}`), - type: "thread.settled", - threadId: thread.id, - occurredAt: at(0), - payload: { ...thread, lastVisitedAt: null }, - } - : sessionUpdated(index, trigger), - ), - Stream.concat( - Stream.fromEffect(Deferred.succeed(handled, undefined)).pipe(Stream.drain), - ), - ), - ); - yield* Deferred.await(handled); - yield* Deferred.succeed(fixture.resume, undefined); - yield* fixture.service.drain; - expect(fixture.snapshotReads()).toBe(1); - }), - ), -); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index d68444867428..a196321e3809 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -32,6 +32,7 @@ import type { PlatformError } from "effect/PlatformError"; import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import { parse as parseYaml } from "yaml"; import * as ServerConfig from "./config.ts"; import * as GitManager from "./git/GitManager.ts"; @@ -45,9 +46,11 @@ import * as Settings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import { removeLinkedWorktree } from "./vcs/worktreeRemoval.ts"; import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { isNetworkPath } from "@t3tools/shared/path"; const decodeCleanupThread = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2AppThreadJson), @@ -55,6 +58,13 @@ const decodeCleanupThread = Schema.decodeUnknownEffect( const decodeCleanupSession = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2ProviderSessionJson), ); +// pnpm 11 writes this file as JSON, older versions as YAML. +const decodePnpmModulesManifest = Schema.decodeUnknownEffect( + Schema.Struct({ + storeDir: Schema.String, + virtualStoreDir: Schema.optional(Schema.String), + }), +); const DAY_MS = 86_400_000; // After a settle, the project's settle script needs time to open its @@ -247,14 +257,40 @@ const make = Effect.gen(function* () { ); }); - const measureWorktree = (worktreePath: string) => - Effect.gen(function* () { + // pnpm imports packages by clone or reflink when its store shares a device + // with the worktree. Those files have one link but share blocks with the + // store, so removing them frees almost nothing. A store on another device + // forces real copies, which still count. + const sharedPnpmVirtualStore = Effect.fnUntraced( + function* (root: string) { + const nodeModules = path.join(root, "node_modules"); + const text = yield* fs.readFileString(path.join(nodeModules, ".modules.yaml")); + const manifest = yield* decodePnpmModulesManifest(yield* Effect.try(() => parseYaml(text))); + // An agent can write this manifest, and statting a network path makes + // Windows sign in to its host with the user's NTLM credentials. + if (isNetworkPath(manifest.storeDir) || isNetworkPath(manifest.virtualStoreDir ?? "")) { + return null; + } + const virtualStore = path.resolve(nodeModules, manifest.virtualStoreDir ?? ".pnpm"); + const [store, virtual] = yield* Effect.all([ + fs.stat(manifest.storeDir), + fs.stat(virtualStore), + ]); + return store.dev === virtual.dev ? virtualStore : null; + }, + Effect.orElseSucceed(() => null), + ); + + const measureWorktree = Effect.fn("storageCleanup.measureWorktree")( + function* (worktreePath: string) { const root = yield* fs.realPath(worktreePath); + const skipped = yield* sharedPnpmVirtualStore(root); const pending = [root]; let entries = 1; let bytes = 0; while (pending.length > 0) { const target = pending.pop()!; + if (target === skipped) continue; // Effect's stat follows links. Probe with readLink first to get lstat // semantics, including skipping dangling links and directory links. const isLink = yield* fs.readLink(target).pipe( @@ -270,8 +306,14 @@ const make = Effect.gen(function* () { if (isLink) continue; if ((yield* fs.realPath(target)) !== target) return null; const stat = yield* fs.stat(target); - if (stat.type === "File") bytes += Number(stat.size); - else if (stat.type === "Directory") { + if (stat.type === "File") { + // Removing a shared hardlink leaves its blocks allocated. + if (Option.getOrElse(stat.nlink, () => 1n) !== 1n) continue; + bytes += Option.match(stat.blocks, { + onNone: () => Number(stat.size), + onSome: (blocks) => Number(blocks * 512n), + }); + } else if (stat.type === "Directory") { const names = yield* fs.readDirectory(target); entries += names.length; if (entries > 2_000_000) return null; @@ -283,19 +325,17 @@ const make = Effect.gen(function* () { } } return bytes; - }).pipe( - Effect.timeout("30 seconds"), - Effect.orElseSucceed(() => null), - ); + }, + Effect.timeout("30 seconds"), + Effect.orElseSucceed(() => null), + ); // The snapshot takes seconds on large databases, so it runs on the reader // worker instead of the event loop. const readThreads = Effect.fn("StorageCleanup.readThreads")(function* () { - const { threads } = yield* projections - .getShellSnapshot({ location: "active" }) - .pipe(NodeSqliteClient.readOnly); + const snapshot = yield* projections.getShellSnapshot().pipe(NodeSqliteClient.readOnly); const projects = yield* projectStore.listShells(); - return { projects, threads }; + return { projects, threads: [...snapshot.threads, ...snapshot.archivedThreads] }; }); const containsProjectRoot = Effect.fn("StorageCleanup.containsProjectRoot")(function* ( @@ -410,6 +450,7 @@ const make = Effect.gen(function* () { ? "No thread uses this worktree" : "No thread uses this folder, and it isn't a git worktree", path: worktreePath, + projectPath: null, threadId: null, threadTitle: null, bytes, @@ -533,6 +574,122 @@ const make = Effect.gen(function* () { return file === undefined ? null : `has ignored files (${file})`; }); + const submoduleRemoval = Effect.fn("StorageCleanup.submoduleRemoval")( + function* (cwd: string, rules: WorktreeCleanupRules) { + const inspect = (args: string[], maxOutputBytes = 64 * 1024) => + git.execute({ + operation: "StorageCleanup.submodules", + cwd, + args, + maxOutputBytes, + }); + const admin = yield* inspect(["rev-parse", "--absolute-git-dir"]); + if (admin.stdoutTruncated || admin.stderrTruncated) + return { force: false, reason: "submodule inspection exceeded the limit" }; + const modules = path.join(admin.stdout.replace(/\n$/, ""), "modules"); + const stored = yield* fs.exists(modules); + if (!stored && !(yield* fs.exists(path.join(cwd, ".gitmodules")))) + return { force: false, reason: null }; + const populated = yield* inspect([ + "submodule", + "foreach", + "--recursive", + "--quiet", + 'printf "%s\\0" "$toplevel/$sm_path"', + ]); + if (populated.stdoutTruncated || populated.stderrTruncated) + return { force: false, reason: "submodule inspection exceeded the limit" }; + const paths = populated.stdout.split("\0").filter(Boolean); + const pending = [modules]; + for (const submodule of paths) { + const directory = yield* inspect(["-C", submodule, "rev-parse", "--absolute-git-dir"]); + if (directory.stdoutTruncated || directory.stderrTruncated) + return { force: false, reason: "submodule inspection exceeded the limit" }; + pending.push(directory.stdout.replace(/\n$/, "")); + } + const visited = new Set(); + while (pending.length > 0) { + const directory = pending.pop()!; + if (!(yield* fs.exists(directory))) continue; + const canonical = yield* fs.realPath(directory); + if (visited.has(canonical)) continue; + visited.add(canonical); + if (visited.size > 10_000) + return { force: false, reason: "submodule inspection exceeded the limit" }; + if ((yield* fs.stat(directory)).type !== "Directory") + return { force: false, reason: "submodule repository could not be inspected" }; + if (yield* fs.exists(path.join(directory, "HEAD"))) { + const unpushed = yield* inspect([ + "--git-dir", + directory, + "--work-tree", + cwd, + "rev-list", + "--max-count=1", + "HEAD", + "--all", + "--reflog", + "--not", + "--remotes", + ]); + if (unpushed.stdoutTruncated || unpushed.stderrTruncated) + return { force: false, reason: "submodule inspection exceeded the limit" }; + if (unpushed.stdout.trim() !== "") + return { force: false, reason: "submodule has commits not on a remote-tracking ref" }; + pending.push(path.join(directory, "modules")); + } else { + for (const name of yield* fs.readDirectory(directory)) + pending.push(path.join(directory, name)); + } + } + // Check files after walking stored history to shorten the gap before forced removal. + for (const submodule of paths) { + const changes = yield* localChangesReason(submodule, rules); + if (changes !== null) return { force: false, reason: `submodule ${changes}` }; + } + const force = stored || paths.length > 0; + if (force && rules.worktreeKeepWhen !== "tracked-changes") { + const root = yield* fs.realPath(cwd); + // Git status and foreach omit files inside deinitialized gitlink directories. + const populatedPaths = new Set(paths.map(pathKey)); + const unchecked: string[] = []; + for (const repository of [root, ...paths]) { + const index = yield* inspect( + ["-C", repository, "ls-files", "--stage", "-z"], + 16 * 1024 * 1024, + ); + if (index.stdoutTruncated || index.stderrTruncated) + return { force: false, reason: "submodule inspection exceeded the limit" }; + for (const entry of index.stdout.split("\0")) { + if (!entry.startsWith("160000 ")) continue; + const submodule = path.resolve(repository, entry.slice(entry.indexOf("\t") + 1)); + if (!inside(root, submodule)) + return { force: false, reason: "submodules could not be inspected" }; + if (!populatedPaths.has(pathKey(submodule)) && (yield* fs.exists(submodule))) + unchecked.push(submodule); + } + } + let inspected = unchecked.length; + while (unchecked.length > 0) { + if (inspected > 10_000) + return { force: false, reason: "submodule inspection exceeded the limit" }; + const target = unchecked.pop()!; + if ( + (yield* fs.realPath(target)) !== path.resolve(target) || + (yield* fs.stat(target)).type !== "Directory" + ) + return { force: false, reason: "submodule has local files" }; + if (path.basename(target) === "node_modules") continue; + const names = yield* fs.readDirectory(target); + inspected += names.length; + for (const name of names) unchecked.push(path.join(target, name)); + } + } + return { force, reason: null }; + }, + Effect.orElseSucceed(() => ({ force: false, reason: "submodules could not be inspected" })), + ); + const cleanupGit = Effect.fn("StorageCleanup.cleanupGit")(function* ( cwd: string, args: string[], @@ -588,6 +745,7 @@ const make = Effect.gen(function* () { )).filter((thread) => thread.worktreePath !== null && thread.branch !== null); const snapshot = yield* readThreads(); const refreshedDefaultRefs = new Map>(); + const defaultBranches = new Map(); const groups = Map.groupBy( snapshot.threads.filter((thread) => thread.worktreePath !== null), (thread) => path.resolve(thread.worktreePath!), @@ -611,6 +769,7 @@ const make = Effect.gen(function* () { outcome: "kept", reason: "", path: worktreePath, + projectPath: project?.workspaceRoot ?? null, threadId: thread.id, threadTitle: thread.title, bytes: null, @@ -680,7 +839,30 @@ const make = Effect.gen(function* () { if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) { const repositoryCwd = path.resolve(project.workspaceRoot); const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd); - const branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote); + const defaultBranchKey = `${repositoryCwd}\0${remote}`; + let branch = defaultBranches.get(defaultBranchKey); + if (branch === undefined) { + branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote); + // Without a local HEAD ref, only the remote can identify its default; + // a conventional name may be a feature branch. + if (branch === null) { + branch = yield* git + .execute({ + operation: "StorageCleanup.resolveDefaultBranch", + cwd: repositoryCwd, + args: ["ls-remote", "--symref", "--", remote, "HEAD"], + timeoutMs: 20_000, + }) + .pipe( + Effect.map( + (result) => + /^ref: refs\/heads\/(.+)\tHEAD\r?$/m.exec(result.stdout)?.[1] ?? null, + ), + Effect.orElseSucceed(() => null), + ); + } + defaultBranches.set(defaultBranchKey, branch); + } if (branch === null) return keep("default branch is unavailable"); const defaultRef = `refs/remotes/${remote}/${branch}`; const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set(); @@ -740,9 +922,9 @@ const make = Effect.gen(function* () { const latestProjects = yield* projectStore.listShells(); if (yield* containsProjectRoot(worktreePath, [project, ...latestProjects])) return "contains a project checkout"; - const latest = (yield* projections.getThreadWorktreePaths()).filter( - (entry) => path.resolve(entry.worktreePath) === worktreePath, - ); + const latest = (yield* projections.getThreadWorktreePaths({ + includeArchived: true, + })).filter((entry) => path.resolve(entry.worktreePath) === worktreePath); if (hasTerminal(worktreePath)) return "open terminal"; if (deleted) { if ( @@ -829,15 +1011,24 @@ const make = Effect.gen(function* () { ) ) return keep("settings changed since check"); - // Clean only untracked files; Git must still refuse removal if a tracked - // edit arrives after our last status check. + // Clean only untracked files. Ordinary worktrees retain Git's final tracked-edit check. if (settings.worktreeKeepWhen === "tracked-changes") yield* cleanupGit(worktreePath, ["clean", "-ffdx"], "git clean"); - yield* cleanupGit( - project.workspaceRoot, - ["-c", "status.showUntrackedFiles=normal", "worktree", "remove", "--", worktreePath], - "git worktree remove", - ); + const submodules = yield* submoduleRemoval(worktreePath, settings); + if (submodules.reason !== null) return keep(submodules.reason); + if (submodules.force) { + const changes = yield* localChangesReason(worktreePath, settings); + if (changes !== null) return keep(changes); + } + yield* removeLinkedWorktree({ + operation: "StorageCleanup.cleanupGit", + cwd: project.workspaceRoot, + worktreePath, + // Git requires force for submodules; recursive checks above cannot make removal atomic. + force: submodules.force, + execute: git.execute, + run: (args) => cleanupGit(project.workspaceRoot, [...args], "git worktree remove"), + }); entries.push({ ...entry, outcome: "removed", reason: sentence(removalReason), bytes }); yield* gitManager.invalidateStatus(project.workspaceRoot); // Preserve branch and path: ProviderTurnStartService recreates the checkout @@ -919,6 +1110,7 @@ const make = Effect.gen(function* () { outcome: "failed", reason: cleanupFailureReason(error), path: null, + projectPath: null, threadId: null, threadTitle: null, bytes: null, @@ -951,6 +1143,7 @@ const make = Effect.gen(function* () { outcome: files > 0 ? "removed" : "kept", reason: files === 0 ? "No expired files" : `Removed ${files} ${category.label}`, path: null, + projectPath: null, threadId: null, threadTitle: null, bytes: files > 0 ? bytes : null, @@ -964,6 +1157,7 @@ const make = Effect.gen(function* () { outcome: "failed", reason: cleanupFailureReason(error), path: null, + projectPath: null, threadId: null, threadTitle: null, bytes: removed.files > 0 ? removed.bytes : null, diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 77b2088863b4..1cc66be7b3df 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -1,6 +1,6 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it, vi } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -16,7 +16,7 @@ import * as HttpServerResponse from "effect/http/HttpServerResponse"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "../config.ts"; -import { getTelemetryIdentifier } from "./Identify.ts"; +import { getTelemetryIdentity } from "./Identify.ts"; import * as AnalyticsService from "./AnalyticsService.ts"; interface RecordedBatchRequest { @@ -57,7 +57,14 @@ const SentBatch = Schema.fromJsonString( batch: Schema.Array( Schema.Struct({ uuid: Schema.String, - properties: Schema.Struct({ telemetryChannel: Schema.String }), + event: Schema.String, + distinct_id: Schema.String, + properties: Schema.Struct({ + telemetryChannel: Schema.String, + installId: Schema.optionalKey(Schema.String), + ownerSource: Schema.optionalKey(Schema.String), + observedBy: Schema.optionalKey(Schema.String), + }), }), ), }), @@ -68,24 +75,31 @@ type Respond = ( request: HttpClientRequest.HttpClientRequest, ) => Effect.Effect; -/** The connection drops after the endpoint read the batch, so it may be stored. */ -const connectionReset: Respond = (request) => - Effect.fail( - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ request, cause: "connection reset" }), - }), - ); - const respondWithStatus = (status: number): Respond => (request) => Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status }))); +const recordTwentyEvents = Effect.gen(function* () { + const analytics = yield* AnalyticsService.AnalyticsService; + for (let index = 0; index < 20; index += 1) { + yield* analytics.record("test.retry", { index }); + } +}); + /** - * Records 20 events, then lets ten minutes of background flushes run against - * a client that answers every send with `respond`. Returns the sent batches. + * Runs `record` (20 events by default), then lets ten minutes of background + * flushes run against a client that answers every send with `respond`. + * Returns the sent batches. */ -const sendForTenMinutes = (respond: Respond) => +const sendForTenMinutes = ( + respond: Respond, + record: Effect.Effect< + void, + never, + AnalyticsService.AnalyticsService | Effect.Services + > = recordTwentyEvents, +) => Effect.gen(function* () { const batches: Array> = []; const client = HttpClient.make((request) => @@ -122,10 +136,7 @@ const sendForTenMinutes = (respond: Respond) => ); yield* Effect.gen(function* () { - const analytics = yield* AnalyticsService.AnalyticsService; - for (let index = 0; index < 20; index += 1) { - yield* analytics.record("test.retry", { index }); - } + yield* record; for (let second = 0; second < 600; second += 1) { yield* TestClock.adjust("1 second"); } @@ -133,54 +144,32 @@ const sendForTenMinutes = (respond: Respond) => return batches; }); -it("retryDelayMs doubles from 2s and stays under the 5 minute cap", () => { - assert.equal(AnalyticsService.retryDelayMs(1, 0), 1_000); - assert.equal(AnalyticsService.retryDelayMs(2, 0.999_999), 4_000); - assert.equal(AnalyticsService.retryDelayMs(30, 0), 150_000); - assert.equal(AnalyticsService.retryDelayMs(30, 0.999_999), 300_000); -}); - it.layer(NodeServices.layer)("AnalyticsService test", (it) => { - it.effect("a batch that keeps failing is retried with the same uuids, then dropped", () => + it.effect("client owner reports cannot use up the server's owner cap", () => Effect.gen(function* () { - const failures = { - "connection reset": connectionReset, - "408": respondWithStatus(408), - "429": respondWithStatus(429), - "503": respondWithStatus(503), - }; - for (const [failure, respond] of Object.entries(failures)) { - const batches = yield* sendForTenMinutes(respond); - assert.equal(batches.length, 5, failure); - const uuids = batches.map((batch) => batch.map((event) => event.uuid).join(",")); - assert.equal(new Set(uuids).size, 1, `${failure}: every retry carries the same uuids`); - assert.equal(new Set(batches[0]?.map((event) => event.uuid)).size, 20, failure); - } - }), - ); + const batches = yield* sendForTenMinutes( + respondWithStatus(204), + Effect.gen(function* () { + const analytics = yield* AnalyticsService.AnalyticsService; + yield* analytics.recordOwners( + Array.from({ length: 1_001 }, (_, index) => ({ + source: "device" as const, + ownerId: index.toString(16).padStart(64, "0"), + })), + "client", + ); + // The event buffer also holds 1,000, so send the client events first. + yield* analytics.flush; + yield* analytics.recordOwners([{ source: "connect", ownerId: "a".repeat(64) }], "server"); + }), + ); + const owners = batches.flat().map((event) => event.properties.observedBy); - it.effect("a batch the endpoint rejects is dropped without a retry", () => - Effect.gen(function* () { - for (const status of [400, 401, 413]) { - const batches = yield* sendForTenMinutes(respondWithStatus(status)); - assert.equal(batches.length, 1, `status ${status}`); - } + assert.equal(owners.filter((observedBy) => observedBy === "client").length, 1_000); + assert.equal(owners.filter((observedBy) => observedBy === "server").length, 1); }), ); - it.effect("events carry the build's telemetry channel, or dev when none is set", () => - Effect.gen(function* () { - const channels = (batches: ReadonlyArray>) => [ - ...new Set(batches.flat().map((event) => event.properties.telemetryChannel)), - ]; - assert.deepEqual(channels(yield* sendForTenMinutes(respondWithStatus(204))), ["dev"]); - - // Stands in for the value the bundler writes in at build time. - vi.stubGlobal("__T3CODE_TELEMETRY_CHANNEL__", "nightly"); - assert.deepEqual(channels(yield* sendForTenMinutes(respondWithStatus(204))), ["nightly"]); - }).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllGlobals()))), - ); - it.effect("flush drains all buffered events across multiple batches", () => Effect.gen(function* () { const capturedRequests: Array = []; @@ -227,8 +216,8 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { yield* Effect.gen(function* () { yield* Layer.launch(layerBatchServer).pipe(Effect.forkScoped); - const telemetryIdentifier = yield* getTelemetryIdentifier; - assert.equal(telemetryIdentifier !== null, true); + const { distinctId } = yield* getTelemetryIdentity; + assert.equal(distinctId !== null, true); const analytics = yield* AnalyticsService.AnalyticsService; for (let index = 0; index < 45; index += 1) { diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 59542498264f..8f402b6682f2 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -24,7 +24,7 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; -import { getTelemetryIdentifier } from "./Identify.ts"; +import { getTelemetryIdentity, type TelemetryOwner } from "./Identify.ts"; interface BufferedAnalyticsEvent { readonly uuid: string; @@ -47,6 +47,10 @@ const FLUSH_INTERVAL_MS = 10_000; const SEND_TIMEOUT = "10 seconds"; const MAX_BATCH_ATTEMPTS = 5; const RETRY_BASE_DELAY_MS = 2_000; +// Clients can report any number of owner ids; past this many per observer, new ones +// are not sent. Each observer has its own cap, so client reports never crowd out the +// server's own. +const MAX_RECORDED_OWNERS = 1_000; const RETRY_MAX_DELAY_MS = 300_000; // The endpoint answers these when the batch itself is wrong (bad body, wrong // api_key, too large), so sending it again cannot help. 408 and 429 are not @@ -62,7 +66,7 @@ const REJECTED_BATCH_STATUSES = new Set([400, 401, 413]); declare const __T3CODE_TELEMETRY_CHANNEL__: "stable" | "nightly" | "preview" | "dev" | undefined; /** `random` is in [0, 1). */ -export function retryDelayMs(failures: number, random: number): number { +function retryDelayMs(failures: number, random: number): number { const ceiling = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** (failures - 1)); return Math.round(ceiling / 2 + (ceiling / 2) * random); } @@ -91,6 +95,16 @@ export class AnalyticsService extends Context.Service< ) => Effect.Effect; readonly flush: Effect.Effect; + + /** + * Records `install.owner_observed` for each owner of this install that the + * server read itself or a connecting client reported. Each owner is sent once + * per server process, so reconnects and repeated reads are free. + */ + readonly recordOwners: ( + owners: ReadonlyArray, + observedBy: "server" | "client", + ) => Effect.Effect; } >()("t3/telemetry/AnalyticsService") { static readonly layerTest = Layer.succeed( @@ -98,6 +112,7 @@ export class AnalyticsService extends Context.Service< AnalyticsService.of({ record: () => Effect.void, flush: Effect.void, + recordOwners: () => Effect.void, }), ); } @@ -123,7 +138,7 @@ export const make = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig; const httpClient = yield* HttpClient.HttpClient; const serverConfig = yield* ServerConfig.ServerConfig; - const identifier = yield* getTelemetryIdentifier; + const { distinctId, installId } = yield* getTelemetryIdentity; const crypto = yield* Crypto.Crypto; const bufferRef = yield* Ref.make>([]); const deliveryRef = yield* Ref.make({ @@ -132,6 +147,9 @@ export const make = Effect.gen(function* () { failures: 0, retryAt: 0, }); + const recordedOwnersRef = yield* Ref.make< + Readonly>> + >({ server: new Set(), client: new Set() }); // The background flush and the shutdown flush must not send the same batch at once. const flushLock = yield* Semaphore.make(1); const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; @@ -175,16 +193,17 @@ export const make = Effect.gen(function* () { const sendBatch = Effect.fn("AnalyticsService.sendBatch")(function* ( events: ReadonlyArray, ) { - if (!telemetryConfig.enabled || !identifier) return; + if (!telemetryConfig.enabled || !distinctId) return; const payload = { api_key: telemetryConfig.posthogKey, batch: events.map((event) => ({ uuid: event.uuid, event: event.event, - distinct_id: identifier, + distinct_id: distinctId, properties: { ...event.properties, + ...(installId === null ? {} : { installId }), $process_person_profile: false, platform: hostPlatform, wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), @@ -267,7 +286,7 @@ export const make = Effect.gen(function* () { const record: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.record")( function* (event, properties) { - if (!telemetryConfig.enabled || !identifier) return; + if (!telemetryConfig.enabled || !distinctId) return; // Telemetry is best effort: an event without a uuid is not sent. The // Node implementation throws (a defect) rather than failing, so catch both. @@ -284,13 +303,36 @@ export const make = Effect.gen(function* () { }, ); + const recordOwners: AnalyticsService["Service"]["recordOwners"] = Effect.fn( + "AnalyticsService.recordOwners", + )((owners, observedBy) => + Effect.forEach( + owners, + ({ source, ownerId }) => + Ref.modify(recordedOwnersRef, (recorded) => { + const key = `${source}:${ownerId}`; + const seen = recorded[observedBy]; + return seen.has(key) || seen.size >= MAX_RECORDED_OWNERS + ? [false, recorded] + : [true, { ...recorded, [observedBy]: new Set(seen).add(key) }]; + }).pipe( + Effect.flatMap((isNew) => + isNew + ? record("install.owner_observed", { ownerSource: source, ownerId, observedBy }) + : Effect.void, + ), + ), + { discard: true }, + ), + ); + yield* Effect.forever(Effect.sleep(FLUSH_INTERVAL_MS).pipe(Effect.flatMap(() => flushWhenDue)), { disableYield: true, }).pipe(Effect.forkScoped); yield* Effect.addFinalizer(() => flush); - return AnalyticsService.of({ record, flush }); + return AnalyticsService.of({ record, flush, recordOwners }); }); export const layer = Layer.effect(AnalyticsService, make); diff --git a/apps/server/src/telemetry/DelegatedTaskAnalytics.test.ts b/apps/server/src/telemetry/DelegatedTaskAnalytics.test.ts deleted file mode 100644 index aea7aec786cd..000000000000 --- a/apps/server/src/telemetry/DelegatedTaskAnalytics.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import { expect, it } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Stream from "effect/Stream"; - -import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; -import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; -import * as AnalyticsService from "./AnalyticsService.ts"; -import * as DelegatedTaskAnalytics from "./DelegatedTaskAnalytics.ts"; - -const parentThreadId = ThreadId.make("parent-private"); - -const subagentEvent = (status: string, origin = "app_owned") => - ({ - type: "subagent.updated", - payload: { - id: "task-private", - threadId: parentThreadId, - origin, - status, - providerInstanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-5-5", - prompt: "secret task", - completionWake: "always", - startedAt: DateTime.makeUnsafe(0), - completedAt: DateTime.makeUnsafe(90_000), - }, - }) as never; - -it.effect("records each delegated task's final outcome once, without ids or prompts", () => { - const recorded: Array<{ event: string; properties: unknown }> = []; - let shellLookups = 0; - return Effect.gen(function* () { - const analytics = yield* DelegatedTaskAnalytics.DelegatedTaskAnalytics; - yield* analytics.onSubagent(subagentEvent("running")); - yield* analytics.onSubagent(subagentEvent("completed", "provider_native")); - // The first parent lookup fails; the next final update still reports the task. - yield* analytics.onSubagent(subagentEvent("completed")); - yield* analytics.onSubagent(subagentEvent("completed")); - yield* analytics.onSubagent(subagentEvent("completed")); - expect(recorded).toEqual([ - { - event: "mcp.delegated_task.finished", - properties: { - status: "completed", - callerProvider: "codex", - callerModel: "gpt-5.5", - targetProvider: "claudeAgent", - targetModel: "claude-opus-5-5", - crossProvider: true, - completionWake: "always", - durationSeconds: 90, - }, - }, - ]); - }).pipe( - Effect.provide( - DelegatedTaskAnalytics.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(Orchestrator.OrchestratorV2)({ - streamDomainEvents: Stream.empty, - getThreadShell: () => - ++shellLookups === 1 - ? Effect.die("thread store unavailable") - : Effect.succeed({ - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.5", - }, - } as never), - }), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([ - { - instanceId: ProviderInstanceId.make("codex"), - driver: "codex", - models: [{ slug: "gpt-5.5", isCustom: false }], - } as never, - { - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: "claudeAgent", - models: [{ slug: "claude-opus-5-5", isCustom: false }], - } as never, - ]), - }), - Layer.succeed( - AnalyticsService.AnalyticsService, - AnalyticsService.AnalyticsService.of({ - record: (event, properties) => - Effect.sync(() => void recorded.push({ event, properties })), - flush: Effect.void, - }), - ), - ), - ), - ), - ), - ); -}); diff --git a/apps/server/src/telemetry/DelegatedTaskAnalytics.ts b/apps/server/src/telemetry/DelegatedTaskAnalytics.ts index e3f50aef6a8d..724a74b5429b 100644 --- a/apps/server/src/telemetry/DelegatedTaskAnalytics.ts +++ b/apps/server/src/telemetry/DelegatedTaskAnalytics.ts @@ -40,8 +40,9 @@ const make = Effect.gen(function* () { // A task can be re-reported with the same final status; count it once per process. const reported = new Set(); - const onSubagent = (event: SubagentEvent) => - Effect.gen(function* () { + // Runs for every subagent update; most return early, so avoid a span per event. + const onSubagent = Effect.fnUntraced( + function* (event: SubagentEvent) { const task = event.payload; if (task.origin !== "app_owned" || !FINAL_STATUSES.has(task.status)) return; if (reported.has(task.id)) return; @@ -79,7 +80,9 @@ const make = Effect.gen(function* () { }); // Only after recording: a failed lookup leaves the next final update to report it. reported.add(task.id); - }).pipe(Effect.ignoreCause); + }, + (effect) => effect.pipe(Effect.ignoreCause), + ); yield* forkParked( Stream.runForEach(orchestrator.streamDomainEvents, (event) => diff --git a/apps/server/src/telemetry/FeatureUsage.test.ts b/apps/server/src/telemetry/FeatureUsage.test.ts index 0be829cbb891..e19e6a1c8968 100644 --- a/apps/server/src/telemetry/FeatureUsage.test.ts +++ b/apps/server/src/telemetry/FeatureUsage.test.ts @@ -1,32 +1,10 @@ -import { CommandId, ORCHESTRATION_V2_WS_METHODS, ThreadId, WS_METHODS } from "@t3tools/contracts"; +import { ORCHESTRATION_V2_WS_METHODS, ThreadId, WS_METHODS } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import { featureUsage } from "./FeatureUsage.ts"; const dispatch = ORCHESTRATION_V2_WS_METHODS.dispatchCommand; const threadId = ThreadId.make("thread-private"); -const commandId = CommandId.make("command-private"); - -it("names user commands with their closed-enum variant and nothing else", () => { - expect( - featureUsage(dispatch, { - type: "checkpoint.rollback", - commandId, - threadId, - scopeId: "scope-private", - checkpointId: "checkpoint-private", - restoreFiles: false, - }), - ).toEqual({ feature: "checkpoint.rollback", variant: "conversation_only" }); - expect( - featureUsage(dispatch, { - type: "thread.runtime-mode.set", - commandId, - threadId, - runtimeMode: "full-access", - }), - ).toEqual({ feature: "thread.runtime_mode.set", variant: "full-access" }); -}); it("ignores messages, housekeeping commands, and untracked RPCs", () => { expect(featureUsage(dispatch, { type: "message.dispatch", text: "secret prompt" })).toBe( diff --git a/apps/server/src/telemetry/Identify.test.ts b/apps/server/src/telemetry/Identify.test.ts index b556499547aa..6fa6c8500817 100644 --- a/apps/server/src/telemetry/Identify.test.ts +++ b/apps/server/src/telemetry/Identify.test.ts @@ -10,6 +10,8 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { CLOUD_LINKED_USER_ID } from "../cloud/config.ts"; import * as ServerConfig from "../config.ts"; import * as Identify from "./Identify.ts"; @@ -39,28 +41,60 @@ const findIdentityLog = ( errorTag: string, ) => logs.find((log) => log.annotations.source === source && log.annotations.errorTag === errorTag); +const unsignedJwt = (claims: Readonly>) => + ["e30", Buffer.from(JSON.stringify(claims)).toString("base64url"), "signature"].join("."); + +const layerIdentity = (prefix: string, logs?: CapturedLog[]) => + Layer.provideMerge( + ServerSecretStore.layer, + ServerConfig.layerTest(process.cwd(), { prefix }), + ).pipe( + Layer.merge( + logs === undefined + ? Layer.empty + : Logger.layer([makeCaptureLogger(logs)], { mergeWithExisting: false }), + ), + ); + +const writeHomeFile = Effect.fn("test.writeHomeFile")(function* ( + homeDirectory: string, + relativePath: string, + contents: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const filePath = path.join(homeDirectory, relativePath); + yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fileSystem.writeFileString(filePath, contents); + return filePath; +}); + it.layer(NodeServices.layer)("telemetry identity", (it) => { - it.effect("uses the persisted anonymous id when provider identities are absent", () => + it.effect("keeps the old distinct_id rule and a fixed install id as logins change", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const homeDirectory = path.join(config.baseDir, "home"); const anonymousId = "persisted-anonymous-id"; - yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId); + const installId = yield* sha256(anonymousId); + const identity = () => Identify.getTelemetryIdentityForHome(homeDirectory); - const identifier = yield* Identify.getTelemetryIdentifierForHome( - path.join(config.baseDir, "home"), - ); + assert.deepEqual(yield* identity(), { distinctId: installId, installId }); - assert.equal(identifier, yield* sha256(anonymousId)); - }).pipe( - Effect.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-identify-anonymous-", - }), - ), - ), + yield* writeHomeFile(homeDirectory, ".claude.json", `{"userID":"claude-machine"}`); + assert.deepEqual(yield* identity(), { + distinctId: yield* sha256("claude-machine"), + installId, + }); + + yield* writeHomeFile(homeDirectory, ".codex/auth.json", `{"auth_mode":"apikey"}`); + assert.equal((yield* identity()).distinctId, yield* sha256("claude-machine")); + + yield* writeHomeFile(homeDirectory, ".codex/auth.json", `{"tokens":{"account_id":"ws-1"}}`); + assert.deepEqual(yield* identity(), { distinctId: yield* sha256("ws-1"), installId }); + }).pipe(Effect.provide(layerIdentity("t3-telemetry-identify-install-"))), ); it.effect("leaves no torn anonymous id behind when its write fails midway", () => @@ -87,19 +121,77 @@ it.layer(NodeServices.layer)("telemetry identity", (it) => { ), }); - const identifier = yield* Identify.getTelemetryIdentifierForHome( + const identity = yield* Identify.getTelemetryIdentityForHome( path.join(config.baseDir, "home"), ).pipe(Effect.provideService(FileSystem.FileSystem, tornWrites)); - assert.isNull(identifier); + assert.deepEqual(identity, { distinctId: null, installId: null }); assert.isFalse(yield* fileSystem.exists(config.anonymousIdPath)); - }).pipe( - Effect.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-identify-torn-", + }).pipe(Effect.provide(layerIdentity("t3-telemetry-identify-torn-"))), + ); + + it.effect("does not overwrite the anonymous id path after a non-NotFound read failure", () => { + const logs: CapturedLog[] = []; + + return Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(config.anonymousIdPath); + + const identity = yield* Identify.getTelemetryIdentityForHome( + path.join(config.baseDir, "home"), + ); + assert.deepEqual(identity, { distinctId: null, installId: null }); + assert.deepEqual(yield* fileSystem.readDirectory(config.anonymousIdPath), []); + + const readLog = findIdentityLog(logs, "anonymous", "TelemetryIdentityReadError"); + assert.equal(readLog?.annotations.filePath, config.anonymousIdPath); + assert.notEqual(readLog?.annotations.platformReason, "NotFound"); + assert.notProperty(readLog?.annotations ?? {}, "cause"); + assert.isUndefined( + findIdentityLog(logs, "anonymous", "TelemetryAnonymousIdPersistenceError"), + ); + }).pipe(Effect.provide(layerIdentity("t3-telemetry-identify-read-", logs))); + }); + + it.effect("reads the Connect account and each provider account as owners", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const path = yield* Path.Path; + const homeDirectory = path.join(config.baseDir, "home"); + yield* secrets.set(CLOUD_LINKED_USER_ID, new TextEncoder().encode("user_connect")); + yield* writeHomeFile( + homeDirectory, + ".codex/auth.json", + JSON.stringify({ + tokens: { + account_id: "team-workspace", + id_token: unsignedJwt({ + sub: "google-oauth2|login-method", + "https://api.openai.com/auth": { user_id: "user-openai-person" }, + }), + }, }), - ), - ), + ); + yield* writeHomeFile( + homeDirectory, + ".claude.json", + JSON.stringify({ + userID: "claude-machine", + oauthAccount: { accountUuid: "claude-person" }, + }), + ); + + const owners = yield* Identify.getServerOwnersForHome(homeDirectory); + + assert.deepEqual(owners, [ + { source: "connect", ownerId: yield* sha256("user_connect") }, + { source: "openai", ownerId: yield* sha256("user-openai-person") }, + { source: "claude", ownerId: yield* sha256("claude-person") }, + ]); + }).pipe(Effect.provide(layerIdentity("t3-telemetry-identify-owners-"))), ); it.effect.each([ @@ -119,135 +211,60 @@ it.layer(NodeServices.layer)("telemetry identity", (it) => { secret: "private-personal-access-token", authJson: (secret: string) => `{"OPENAI_API_KEY":null,"personal_access_token":"${secret}"}`, }, - ])("falls back quietly when Codex authenticates with $login", ({ secret, authJson }) => { - const logs: CapturedLog[] = []; - const logger = makeCaptureLogger(logs); - - return Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const homeDirectory = path.join(config.baseDir, "home"); - const codexAuthPath = path.join(homeDirectory, ".codex", "auth.json"); - const anonymousId = "tokenless-codex-anonymous-id"; - - yield* fileSystem.makeDirectory(path.dirname(codexAuthPath), { recursive: true }); - yield* fileSystem.writeFileString(codexAuthPath, authJson(secret)); - yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId); - - const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory); - - assert.equal(identifier, yield* sha256(anonymousId)); - assert.isUndefined(findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError")); - assert.isUndefined(findIdentityLog(logs, "codex", "TelemetryIdentityReadError")); - const allLogs = logs - .map((log) => - [String(log.message), ...Object.values(log.annotations).map(String)].join("\n"), - ) - .join("\n"); - assert.notInclude(allLogs, secret); - }).pipe( - Effect.provide( - Layer.merge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-identify-tokenless-", - }), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); - }); - - it.effect("logs structured decode context and falls back from malformed Codex auth", () => { - const logs: CapturedLog[] = []; - const logger = makeCaptureLogger(logs); - - return Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const homeDirectory = path.join(config.baseDir, "home"); - const codexAuthPath = path.join(homeDirectory, ".codex", "auth.json"); - const anonymousId = "decode-fallback-anonymous-id"; - const privateAccessToken = "private-codex-access-token"; - - yield* fileSystem.makeDirectory(path.dirname(codexAuthPath), { recursive: true }); - yield* fileSystem.writeFileString( - codexAuthPath, - `{"tokens":{"access_token":"${privateAccessToken}"}}`, - ); - yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId); - - const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory); - - assert.equal(identifier, yield* sha256(anonymousId)); - const decodeLog = findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError"); - assert.isDefined(decodeLog); - assert.equal( - decodeLog?.message, - `Failed to decode codex telemetry identity at '${codexAuthPath}'.`, - ); + ])( + "adds no owner and logs nothing when Codex authenticates with $login", + ({ secret, authJson }) => { + const logs: CapturedLog[] = []; - assert.equal(decodeLog?.annotations.filePath, codexAuthPath); - assert.equal(decodeLog?.annotations.causeKind, "schema"); - assert.notProperty(decodeLog?.annotations ?? {}, "cause"); - const errorStack = decodeLog?.annotations.errorStack; - assert.isString(errorStack); - assert.include(errorStack, "Failed to decode codex telemetry identity"); - const annotations = Object.values(decodeLog?.annotations ?? {}) - .map(String) - .join("\n"); - assert.notInclude(annotations, privateAccessToken); - }).pipe( - Effect.provide( - Layer.merge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-identify-decode-", - }), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); - }); - - it.effect("does not overwrite the anonymous id path after a non-NotFound read failure", () => { - const logs: CapturedLog[] = []; - const logger = makeCaptureLogger(logs); - - return Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const homeDirectory = path.join(config.baseDir, "home"); + return Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; + const homeDirectory = path.join(config.baseDir, "home"); + yield* writeHomeFile(homeDirectory, ".codex/auth.json", authJson(secret)); - yield* fileSystem.makeDirectory(config.anonymousIdPath); + assert.deepEqual(yield* Identify.getServerOwnersForHome(homeDirectory), []); + assert.deepEqual(logs, []); + }).pipe(Effect.provide(layerIdentity("t3-telemetry-identify-tokenless-", logs))); + }, + ); - const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory); + it.effect( + "warns about malformed Codex auth without its tokens and keeps the other owners", + () => { + const logs: CapturedLog[] = []; - assert.isNull(identifier); - assert.deepEqual(yield* fileSystem.readDirectory(config.anonymousIdPath), []); + return Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; + const homeDirectory = path.join(config.baseDir, "home"); + const privateAccessToken = "private-codex-access-token"; + const codexAuthPath = yield* writeHomeFile( + homeDirectory, + ".codex/auth.json", + `{"tokens":{"access_token":"${privateAccessToken}"}}`, + ); + yield* writeHomeFile( + homeDirectory, + ".claude.json", + `{"oauthAccount":{"accountUuid":"claude-person"}}`, + ); - const readLog = findIdentityLog(logs, "anonymous", "TelemetryIdentityReadError"); - assert.isDefined(readLog); - assert.equal(readLog?.annotations.filePath, config.anonymousIdPath); - assert.equal(readLog?.annotations.causeKind, "platform"); - assert.notEqual(readLog?.annotations.platformReason, "NotFound"); - assert.notProperty(readLog?.annotations ?? {}, "cause"); - const errorStack = readLog?.annotations.errorStack; - assert.isString(errorStack); - assert.include(errorStack, "Failed to read anonymous telemetry identity"); - assert.isUndefined( - findIdentityLog(logs, "anonymous", "TelemetryAnonymousIdPersistenceError"), - ); - }).pipe( - Effect.provide( - Layer.merge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-identify-read-", - }), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); - }); + assert.deepEqual(yield* Identify.getServerOwnersForHome(homeDirectory), [ + { source: "claude", ownerId: yield* sha256("claude-person") }, + ]); + const decodeLog = findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError"); + assert.equal( + decodeLog?.message, + `Failed to decode codex telemetry identity at '${codexAuthPath}'.`, + ); + assert.equal(decodeLog?.annotations.causeKind, "schema"); + assert.notInclude( + Object.values(decodeLog?.annotations ?? {}) + .map(String) + .join("\n"), + privateAccessToken, + ); + }).pipe(Effect.provide(layerIdentity("t3-telemetry-identify-decode-", logs))); + }, + ); }); diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index 61c4d3535915..05e657adbe34 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -1,33 +1,66 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; -import * as Hex from "effect/encoding/Hex"; +import * as Base64Url from "effect/encoding/Base64Url"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; -import * as ServerConfig from "../config.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { hashTelemetryId } from "@t3tools/shared/telemetryId"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { CLOUD_LINKED_USER_ID } from "../cloud/config.ts"; +import * as ServerConfig from "../config.ts"; /** * Codex writes `tokens` only for ChatGPT logins and omits the key for API-key, * agent-identity, and personal-access-token logins, so its absence is a - * supported install and not a malformed file. + * supported install and not a malformed file. `account_id` is the ChatGPT + * workspace, which a whole Team shares; the `id_token` names the person. */ const CodexAuthJsonSchema = Schema.Struct({ tokens: Schema.optionalKey( Schema.Struct({ account_id: Schema.String, + id_token: Schema.optionalKey(Schema.String), }), ), }); +/** `userID` is random per machine. `oauthAccount` is present only while Claude Code is logged in. */ const ClaudeJsonSchema = Schema.Struct({ - userID: Schema.String, + userID: Schema.optionalKey(Schema.String), + oauthAccount: Schema.optionalKey( + Schema.Struct({ + accountUuid: Schema.optionalKey(Schema.String), + }), + ), }); +// `sub` names the login method (`google-oauth2|…`), so one person can have several. +// `user_id` (`user-…`) is the OpenAI account and is the same for every login. +const IdTokenClaims = Schema.Struct({ + "https://api.openai.com/auth": Schema.Struct({ user_id: Schema.NonEmptyString }), +}); +const decodeIdTokenClaims = Schema.decodeUnknownOption(Schema.fromJsonString(IdTokenClaims)); + +/** + * Who an install belongs to, from strongest to weakest. `connect` is the T3 + * Connect account. `openai` and `claude` are provider accounts. `device` is a + * client device that connected. + */ +export const TelemetryOwnerSource = Schema.Literals(["connect", "openai", "claude", "device"]); +export type TelemetryOwnerSource = typeof TelemetryOwnerSource.Type; + +export interface TelemetryOwner { + readonly source: TelemetryOwnerSource; + /** `hashTelemetryId` of the source's raw id. */ + readonly ownerId: string; +} + export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]); export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type; @@ -83,25 +116,11 @@ export class TelemetryAnonymousIdPersistenceError extends Schema.TaggedError()( - "TelemetryIdentityHashError", - { - source: TelemetryIdentitySource, - algorithm: Schema.Literal("SHA-256"), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to hash ${this.source} telemetry identity with ${this.algorithm}.`; - } -} - type TelemetryIdentityError = | TelemetryIdentityReadError | TelemetryIdentityDecodeError | TelemetryAnonymousIdGenerationError - | TelemetryAnonymousIdPersistenceError - | TelemetryIdentityHashError; + | TelemetryAnonymousIdPersistenceError; const decodeCodexAuthJson = Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)); const decodeClaudeJson = Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)); @@ -155,72 +174,48 @@ const readIdentityFile = ( }), ); -const hash = (source: TelemetryIdentitySource, value: string) => - Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.digest("SHA-256", new TextEncoder().encode(value))), - Effect.map(Hex.encode), - Effect.mapError( - (cause) => - new TelemetryIdentityHashError({ - source, - algorithm: "SHA-256", - cause, - }), - ), - ); +/** + * The OpenAI account id in a ChatGPT id_token, read without verifying it: + * analytics needs only a stable id. Codex CLI and T3's own sign-in both use it. + */ +export const openAiUserId = (idToken: string) => { + const payload = Base64Url.decodeString(idToken.split(".")[1] ?? ""); + return Result.isSuccess(payload) + ? Option.map( + decodeIdTokenClaims(payload.success), + (claims) => claims["https://api.openai.com/auth"].user_id, + ) + : Option.none(); +}; -const getCodexAccountId = Effect.fn("TelemetryIdentity.getCodexAccountId")(function* ( - homeDirectory: string, +const readJsonIdentityFile = Effect.fn("TelemetryIdentity.readJsonIdentityFile")(function* ( + source: "codex" | "claude", + filePath: string, + decode: (encoded: string) => Effect.Effect, ) { const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const authJsonPath = path.join(homeDirectory, ".codex", "auth.json"); - const encoded = yield* readIdentityFile(fileSystem, "codex", authJsonPath); + const encoded = yield* readIdentityFile(fileSystem, source, filePath); if (Option.isNone(encoded)) { - return Option.none(); + return Option.none(); } - const authJson = yield* decodeCodexAuthJson(encoded.value).pipe( - Effect.mapError( - (cause) => - new TelemetryIdentityDecodeError({ - source: "codex", - filePath: authJsonPath, - cause, - }), - ), + return yield* decode(encoded.value).pipe( + Effect.asSome, + Effect.mapError((cause) => new TelemetryIdentityDecodeError({ source, filePath, cause })), ); - - return authJson.tokens === undefined - ? Option.none() - : Option.some(authJson.tokens.account_id); }); -const getClaudeUserId = Effect.fn("TelemetryIdentity.getClaudeUserId")(function* ( - homeDirectory: string, -) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const claudeJsonPath = path.join(homeDirectory, ".claude.json"); - const encoded = yield* readIdentityFile(fileSystem, "claude", claudeJsonPath); - if (Option.isNone(encoded)) { - return Option.none(); - } - const claudeJson = yield* decodeClaudeJson(encoded.value).pipe( - Effect.mapError( - (cause) => - new TelemetryIdentityDecodeError({ - source: "claude", - filePath: claudeJsonPath, - cause, - }), - ), +// A missing or unreadable file only removes that owner; it never blocks the others. +const skipOnError = ( + effect: Effect.Effect< + Option.Option, + TelemetryIdentityReadError | TelemetryIdentityDecodeError, + R + >, +) => + effect.pipe( + Effect.catch((error) => logTelemetryIdentityError(error).pipe(Effect.as(Option.none()))), ); - return Option.some(claudeJson.userID); -}); - const upsertAnonymousId = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const { anonymousIdPath } = yield* ServerConfig.ServerConfig; @@ -255,54 +250,97 @@ const upsertAnonymousId = Effect.gen(function* () { return anonymousId; }); -export const getTelemetryIdentifierForHome = Effect.fn("getTelemetryIdentifierForHome")( - function* (homeDirectory: string) { - const codexAccountId = yield* getCodexAccountId(homeDirectory).pipe( - Effect.catchTags({ - TelemetryIdentityReadError: (error) => - logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), - TelemetryIdentityDecodeError: (error) => - logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), - }), - ); - if (Option.isSome(codexAccountId)) { - return yield* hash("codex", codexAccountId.value); - } - - const claudeUserId = yield* getClaudeUserId(homeDirectory).pipe( - Effect.catchTags({ - TelemetryIdentityReadError: (error) => - logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), - TelemetryIdentityDecodeError: (error) => - logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), - }), - ); - if (Option.isSome(claudeUserId)) { - return yield* hash("claude", claudeUserId.value); - } - - const anonymousId = yield* upsertAnonymousId.pipe( - Effect.asSome, - Effect.catchTags({ - TelemetryIdentityReadError: (error) => - logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), - TelemetryAnonymousIdGenerationError: (error) => - logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), - TelemetryAnonymousIdPersistenceError: (error) => - logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), - }), - ); - if (Option.isSome(anonymousId)) { - return yield* hash("anonymous", anonymousId.value); - } +const readProviderLogins = Effect.fn("TelemetryIdentity.readProviderLogins")(function* ( + homeDirectory: string, +) { + const path = yield* Path.Path; + const codexTokens = yield* readJsonIdentityFile( + "codex", + path.join(homeDirectory, ".codex", "auth.json"), + decodeCodexAuthJson, + ).pipe( + skipOnError, + Effect.map(Option.flatMap((authJson) => Option.fromUndefinedOr(authJson.tokens))), + ); + const claudeJson = yield* readJsonIdentityFile( + "claude", + path.join(homeDirectory, ".claude.json"), + decodeClaudeJson, + ).pipe(skipOnError); + return { codexTokens, claudeJson }; +}); - return null; - }, - Effect.tapError(logTelemetryIdentityError), - Effect.orElseSucceed(() => null), -); +/** + * `distinctId` keeps the rule every earlier build used, so analytics stay + * continuous: the hashed Codex `account_id`, else the Claude machine `userID`, + * else the install's anonymous id. It can change when someone logs in, and a + * Codex Team shares one. `installId` is the hashed anonymous id, which never + * changes for an install; count installs and people with it. + * + * A null `distinctId` turns telemetry off. A missing file falls through quietly, + * and so does an API-key-only Codex auth.json. Only unreadable or malformed + * files warn. + */ +export const getTelemetryIdentityForHome = Effect.fn("getTelemetryIdentityForHome")(function* ( + homeDirectory: string, +) { + const { codexTokens, claudeJson } = yield* readProviderLogins(homeDirectory); + const installId = yield* upsertAnonymousId.pipe( + Effect.map(hashTelemetryId), + Effect.catch((error) => logTelemetryIdentityError(error).pipe(Effect.as(null))), + ); + const providerId = Option.orElse( + Option.map(codexTokens, (tokens) => tokens.account_id), + () => Option.flatMap(claudeJson, (json) => Option.fromUndefinedOr(json.userID)), + ); + return { + distinctId: Option.isSome(providerId) ? hashTelemetryId(providerId.value) : installId, + installId, + }; +}); -export const getTelemetryIdentifier = Effect.flatMap( +export const getTelemetryIdentity = Effect.flatMap( HostProcess.HomeDirectory, - getTelemetryIdentifierForHome, + getTelemetryIdentityForHome, ); + +/** + * Owners the server can read without a client: the linked T3 Connect account + * and the provider logins under `homeDirectory`. Clients add `connect` and + * `device` owners when they connect. + */ +export const getServerOwnersForHome = Effect.fn("getServerOwnersForHome")(function* ( + homeDirectory: string, +) { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const connectUser = yield* secrets.get(CLOUD_LINKED_USER_ID).pipe( + Effect.map(Option.map((bytes) => new TextDecoder().decode(bytes))), + Effect.catch((cause) => + Effect.logWarning("Failed to read the linked T3 Connect account for telemetry", { + errorTag: cause._tag, + }).pipe(Effect.as(Option.none())), + ), + ); + const { codexTokens, claudeJson } = yield* readProviderLogins(homeDirectory); + + const raw: ReadonlyArray]> = [ + ["connect", connectUser], + [ + "openai", + Option.flatMap(codexTokens, (tokens) => + Option.flatMap(Option.fromUndefinedOr(tokens.id_token), openAiUserId), + ), + ], + [ + "claude", + Option.flatMap(claudeJson, (json) => Option.fromUndefinedOr(json.oauthAccount?.accountUuid)), + ], + ]; + return raw.flatMap(([source, value]): ReadonlyArray => + Option.isSome(value) && value.value.trim() !== "" + ? [{ source, ownerId: hashTelemetryId(value.value) }] + : [], + ); +}); + +export const getServerOwners = Effect.flatMap(HostProcess.HomeDirectory, getServerOwnersForHome); diff --git a/apps/server/src/telemetry/ProviderDimensions.test.ts b/apps/server/src/telemetry/ProviderDimensions.test.ts index 67b33fe6f962..d88010f600e3 100644 --- a/apps/server/src/telemetry/ProviderDimensions.test.ts +++ b/apps/server/src/telemetry/ProviderDimensions.test.ts @@ -1,13 +1,7 @@ import { ProviderInstanceId, ThreadId, type ServerProvider } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; -import { - type AgentThread, - agentToolProperties, - handoffSettings, - callerOrigin, - toolOutcome, -} from "./ProviderDimensions.ts"; +import { type AgentThread, agentToolProperties, handoffSettings } from "./ProviderDimensions.ts"; const provider = ( instanceId: string, @@ -43,79 +37,6 @@ const thread = ( ...overrides, }); -it("relates the calling agent to each agent that received work", () => { - expect( - agentToolProperties({ - tool: "create_threads", - providers, - caller: thread("work-codex", "gpt-5.5", { createdBy: "agent", creationSource: "mcp" }), - callerDepth: 1, - targets: [ - thread("claudeAgent", "claude-opus-5-5", { interactionMode: "plan" }), - thread("work-codex", "gpt-5.5"), - ], - outcome: { outcome: "ok" }, - settings: { batchSize: 2, targetChosen: true }, - durationMs: 42, - }), - ).toEqual([ - { - tool: "create_threads", - callerProvider: "codex", - callerModel: "gpt-5.5", - callerOrigin: "agent", - callerDepth: 1, - durationMs: 42, - outcome: "ok", - batchSize: 2, - targetChosen: true, - targetProvider: "claudeAgent", - targetModel: "claude-opus-5-5", - targetRuntimeMode: "full-access", - targetInteractionMode: "plan", - crossProvider: true, - }, - { - tool: "create_threads", - callerProvider: "codex", - callerModel: "gpt-5.5", - callerOrigin: "agent", - callerDepth: 1, - durationMs: 42, - outcome: "ok", - batchSize: 2, - targetChosen: true, - targetProvider: "codex", - targetModel: "gpt-5.5", - targetRuntimeMode: "full-access", - targetInteractionMode: "default", - crossProvider: false, - }, - ]); -}); - -it("reports a non-handoff tool from the credential's provider alone", () => { - expect( - agentToolProperties({ - tool: "preview_click", - providers, - caller: undefined, - callerProviderInstanceId: ProviderInstanceId.make("claudeAgent"), - targets: [], - outcome: { outcome: "error", errorCode: "capability_denied" }, - durationMs: 3, - }), - ).toEqual([ - { - tool: "preview_click", - callerProvider: "claudeAgent", - durationMs: 3, - outcome: "error", - errorCode: "capability_denied", - }, - ]); -}); - it("omits custom, user-configured, and unknown models and instance names", () => { expect( agentToolProperties({ @@ -146,40 +67,6 @@ it("omits custom, user-configured, and unknown models and instance names", () => ]); }); -it("reads handoff settings from enums and flags, never from prompts or ids", () => { - expect( - handoffSettings( - "delegate_task", - { task: "secret", mode: "wait", target: { driverKind: "claudeAgent" } }, - { taskId: "t", waitTimedOut: true }, - ), - ).toEqual({ targetChosen: true, mode: "wait", waitTimedOut: true }); - expect(handoffSettings("delegate_task", { task: "secret" }, { waitTimedOut: false })).toEqual({ - targetChosen: false, - mode: "async", - }); - expect( - handoffSettings( - "t3_thread_launch", - { message: "secret", workspaceStrategy: { type: "worktree", branch: "private" } }, - {}, - ), - ).toEqual({ targetChosen: false, workspace: "worktree" }); - expect(handoffSettings("t3_thread_launch", { scratch: true }, {})).toMatchObject({ - workspace: "scratch", - }); - expect( - handoffSettings( - "create_threads", - { threads: [{ prompt: "a" }, { prompt: "b", target: {} }] }, - {}, - ), - ).toEqual({ batchSize: 2, targetChosen: true }); - expect(handoffSettings("t3_thread_send", { message: "secret" }, { delivery: "steered" })).toEqual( - { delivery: "steered" }, - ); -}); - it("drops input strings outside the schema's literals, since rejected calls are recorded too", () => { expect(handoffSettings("delegate_task", { mode: "/home/me/secret" }, undefined)).toEqual({ targetChosen: false, @@ -189,38 +76,3 @@ it("drops input strings outside the schema's literals, since rejected calls are handoffSettings("t3_thread_launch", { workspaceStrategy: { type: "/home/me/secret" } }, {}), ).toEqual({ targetChosen: false, workspace: "root" }); }); - -it("reports crossProvider only when both providers resolve", () => { - const [event] = agentToolProperties({ - tool: "t3_thread_send", - providers, - caller: thread("work-codex", "gpt-5.5"), - targets: [thread("removed-instance", "gpt-5.5")], - outcome: { outcome: "ok" }, - }); - expect(event).toMatchObject({ callerProvider: "codex", targetProvider: "unknown" }); - expect(event).not.toHaveProperty("crossProvider"); -}); - -it("reads failure codes from declared tool errors even without isError", () => { - expect( - toolOutcome({ - structuredContent: { - _tag: "OrchestratorMcpFailure", - code: "capability_denied", - message: "private detail", - }, - }), - ).toEqual({ outcome: "error", errorCode: "capability_denied" }); - expect(toolOutcome({ isError: false, structuredContent: { threadId: "t" } })).toEqual({ - outcome: "ok", - }); - expect(toolOutcome(undefined)).toEqual({ outcome: "error", errorCode: "exception" }); -}); - -it("tells user, agent, system, and scheduled work apart", () => { - expect(callerOrigin({ thread: { createdBy: "user" }, scheduledRun: false })).toBe("user"); - expect(callerOrigin({ thread: { createdBy: "agent" }, scheduledRun: false })).toBe("agent"); - expect(callerOrigin({ thread: { createdBy: "system" }, scheduledRun: false })).toBe("system"); - expect(callerOrigin({ thread: { createdBy: "user" }, scheduledRun: true })).toBe("scheduler"); -}); diff --git a/apps/server/src/telemetry/ProviderDimensions.ts b/apps/server/src/telemetry/ProviderDimensions.ts index 4a668ee5af78..4e0447a1e2c4 100644 --- a/apps/server/src/telemetry/ProviderDimensions.ts +++ b/apps/server/src/telemetry/ProviderDimensions.ts @@ -78,7 +78,7 @@ export const MAX_REPORTED_DEPTH = 5; * Who started the work that made this call. Scheduled runs keep the task * creator's provenance, so the run's message marks them instead. */ -export function callerOrigin(input: { +function callerOrigin(input: { readonly thread: Pick; readonly scheduledRun: boolean; }) { diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index c864ba28e5c0..413436ea452c 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -4,38 +4,27 @@ import { DEFAULT_TERMINAL_ID, type TerminalAttachStreamEvent, type TerminalEvent, - type TerminalMetadataStreamEvent, type TerminalOpenInput, type TerminalRestartInput, ProviderDriverKind, ProviderInstanceId, - ServerSettingsError, TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Data from "effect/Data"; -import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Path from "effect/Path"; -import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; -import * as References from "effect/References"; -import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; -import * as TestClock from "effect/testing/TestClock"; -import { ChildProcessSpawner } from "effect/process"; import { expect, vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -44,6 +33,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "./Manager.ts"; +import { terminalOutputStream } from "./OutputProtocol.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; vi.mock("effect/SynchronizedRef", async (importOriginal) => { @@ -51,11 +41,9 @@ vi.mock("effect/SynchronizedRef", async (importOriginal) => { return { ...original, make: vi.fn(original.make) }; }); -const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +// The server omits DOM types, which include TypeScript's WebAssembly declarations. -class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ - readonly message: string; -}> {} +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); class FakePtyProcess implements PtyAdapter.PtyProcess { readonly writes: string[] = []; @@ -170,25 +158,43 @@ class FakePtyAdapter { } } -const waitFor = ( - predicate: Effect.Effect, - timeout: Duration.Input = 800, -): Effect.Effect => - predicate.pipe( - Effect.filterOrFail( - (done) => done, - () => new WaitForConditionError({ message: "Condition not met" }), - ), - Effect.retry(Schedule.spaced("15 millis")), - Effect.timeoutOption(timeout), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail(new WaitForConditionError({ message: "Timed out waiting for condition" })), - onSome: () => Effect.void, - }), - ), - ); +// Publishes 9 MiB of output, past a stream's 8 MiB budget, while nothing pulls. +const publishPastOutputBudget = ( + manager: TerminalManager.TerminalManager["Service"], + process: FakePtyProcess, +) => + Effect.gen(function* () { + const published = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "output" && event.data === "last\n" + ? Deferred.succeed(published, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + for (let index = 0; index < 9; index++) process.emitData("x".repeat(1024 * 1024)); + process.emitData("last\n"); + yield* Deferred.await(published); + unsubscribe(); + }); + +// Holds the next spawn, and with it the thread lock, until `release` succeeds. +class GatedPtyAdapter extends FakePtyAdapter { + gate: { + readonly spawning: Deferred.Deferred; + readonly release: Deferred.Deferred; + } | null = null; + + override spawn( + input: PtyAdapter.PtySpawnInput, + ): Effect.Effect { + const gate = this.gate; + this.gate = null; + if (!gate) return super.spawn(input); + return Deferred.succeed(gate.spawning, undefined).pipe( + Effect.andThen(Deferred.await(gate.release)), + Effect.andThen(super.spawn(input)), + ); + } +} function openInput(overrides: Partial = {}): TerminalOpenInput { return { @@ -217,23 +223,6 @@ const historyLogPath = (logsDir: string, threadId = "thread-1") => Effect.map(({ join }) => join(logsDir, `terminal_${Base64Url.encode(threadId)}.log`)), ); -const multiTerminalHistoryLogPath = ( - logsDir: string, - threadId = "thread-1", - terminalId = DEFAULT_TERMINAL_ID, -) => - Effect.service(Path.Path).pipe( - Effect.map(({ join }) => { - const threadPart = `terminal_${Base64Url.encode(threadId)}`; - return join( - logsDir, - terminalId === DEFAULT_TERMINAL_ID - ? `${threadPart}.log` - : `${threadPart}_${Base64Url.encode(terminalId)}.log`, - ); - }), - ); - interface CreateManagerOptions { shellResolver?: () => string; env?: NodeJS.ProcessEnv; @@ -248,6 +237,9 @@ interface CreateManagerOptions { >; subprocessPollIntervalMs?: number; processKillGraceMs?: number; + registerTerminalProcesses?: Parameters< + typeof TerminalManager.makeWithOptions + >[0]["registerTerminalProcesses"]; maxRetainedInactiveSessions?: number; historyByteLimit?: number; ptyAdapter?: FakePtyAdapter; @@ -298,6 +290,9 @@ const createManager = ( ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } : {}), processKillGraceMs: options.processKillGraceMs ?? 1, + ...(options.registerTerminalProcesses !== undefined + ? { registerTerminalProcesses: options.registerTerminalProcesses } + : {}), ...(options.maxRetainedInactiveSessions !== undefined ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), @@ -416,19 +411,6 @@ it("bounds long partial lines and joins surrogate pairs across chunk boundaries" } }); -it("preserves retained lines as older storage is compacted", () => { - for (const maxLines of [3, 5_000]) { - let expected = ""; - const history = new TerminalManager.BoundedTerminalHistory(maxLines, expected); - for (let batch = 0; batch < 40; batch += 1) { - const chunk = Array.from({ length: 300 }, (_, line) => `${batch}:${line}\n`).join(""); - history.append(chunk); - expected = retainedHistory(expected + chunk, maxLines); - expect(history.value()).toBe(expected); - } - } -}); - it.layer( Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), { excludeTestServices: true }, @@ -450,32 +432,6 @@ it.layer( }), ); - it.effect("attaches to running sessions without restarting them", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - - yield* manager.open(openInput()); - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream( - { - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cols: 100, - rows: 40, - }, - (event) => Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); - expect(snapshot).toBeDefined(); - if (!snapshot || snapshot.type !== "snapshot") return; - assert.equal(snapshot.snapshot.threadId, "thread-1"); - assert.equal(snapshot.snapshot.terminalId, DEFAULT_TERMINAL_ID); - expect(ptyAdapter.spawnInputs).toHaveLength(1); - }), - ); - it.effect("keeps attach streams live when a terminal id is closed and reopened", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); @@ -604,91 +560,6 @@ it.layer( }), ); - it.effect("attaches to exited sessions without restarting them", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents } = yield* createManager(); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), - "1200 millis", - ); - - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream( - openInput({ - env: { - T3CODE_WORKTREE_PATH: "/tmp/should-not-restart", - }, - worktreePath: "/tmp/should-not-restart", - }), - (event) => Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); - expect(snapshot).toBeDefined(); - if (!snapshot || snapshot.type !== "snapshot") return; - assert.equal(snapshot.snapshot.status, "exited"); - assert.equal(snapshot.snapshot.worktreePath, null); - expect(ptyAdapter.spawnInputs).toHaveLength(1); - }), - ); - - it.effect("restarts inactive sessions from attach only when requested", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents } = yield* createManager(); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), - "1200 millis", - ); - - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream( - { - ...openInput({ - env: { - T3CODE_WORKTREE_PATH: "/tmp/restart-requested", - }, - worktreePath: "/tmp/restart-requested", - }), - restartIfNotRunning: true, - }, - (event) => Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); - expect(snapshot).toBeDefined(); - if (!snapshot || snapshot.type !== "snapshot") return; - assert.equal(snapshot.snapshot.status, "running"); - assert.equal(snapshot.snapshot.worktreePath, "/tmp/restart-requested"); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - }), - ); - - const makeDirectory = (filePath: string) => - Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => - fs.makeDirectory(filePath, { recursive: true }), - ); - - const chmod = (filePath: string, mode: number) => - Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode)); - const pathExists = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.exists(filePath)); @@ -700,65 +571,6 @@ it.layer( fs.writeFileString(filePath, contents), ); - it.effect("reports a missing cwd without an artificial cause", () => - Effect.gen(function* () { - const path = yield* Path.Path; - - const { manager, baseDir } = yield* createManager(); - const cwd = path.join(baseDir, "missing-cwd"); - const error = yield* Effect.flip(manager.open(openInput({ cwd }))); - - expect(error).toMatchObject({ - _tag: "TerminalCwdNotFoundError", - cwd, - }); - expect("cause" in error).toBe(false); - }), - ); - - it.effect("reports a cwd that is not a directory", () => - Effect.gen(function* () { - const path = yield* Path.Path; - - const { manager, baseDir } = yield* createManager(); - const cwd = path.join(baseDir, "cwd-file"); - yield* writeFileString(cwd, "not a directory"); - const error = yield* Effect.flip(manager.open(openInput({ cwd }))); - - expect(error).toMatchObject({ - _tag: "TerminalCwdNotDirectoryError", - cwd, - }); - expect("cause" in error).toBe(false); - }), - ); - - it.effect("preserves non-notFound cwd stat failures", () => - Effect.gen(function* () { - if ((yield* HostProcess.Platform) === "win32") return; - - const path = yield* Path.Path; - - const { manager, baseDir } = yield* createManager(); - const blockedRoot = path.join(baseDir, "blocked-root"); - const blockedCwd = path.join(blockedRoot, "cwd"); - yield* makeDirectory(blockedCwd); - yield* chmod(blockedRoot, 0o000); - - const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe( - Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)), - ); - - expect(error).toMatchObject({ - _tag: "TerminalCwdStatError", - cwd: blockedCwd, - cause: { - _tag: "PlatformError", - }, - }); - }), - ); - it.effect("handles an exit replayed during subscription after publishing startup", () => Effect.gen(function* () { const ptyAdapter = new FakePtyAdapter(); @@ -789,1268 +601,273 @@ it.layer( }), ); - it.effect("supports asynchronous PTY spawn effects", () => + it.effect("supports multiple terminals per thread independently", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput({ terminalId: "default" })); + yield* manager.open(openInput({ terminalId: "term-2" })); - const snapshot = yield* manager.open(openInput()); + const first = ptyAdapter.processes[0]; + const second = ptyAdapter.processes[1]; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + if (!first || !second) return; - assert.equal(snapshot.status, "running"); - expect(ptyAdapter.spawnInputs).toHaveLength(1); - expect(ptyAdapter.processes).toHaveLength(1); + yield* manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); + yield* manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); + + expect(first.writes).toEqual(["pwd\n"]); + expect(second.writes).toEqual(["ls\n"]); + expect(ptyAdapter.spawnInputs).toHaveLength(2); }), ); - it.effect("forwards write and resize to active pty process", () => + it.effect("closes only a thread's idle shells, ignoring a helper forked from the shell", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; + const { manager, ptyAdapter } = yield* createManager(5, { + processTable: Effect.succeed([ + { pid: 9000, ppid: 1, name: "zsh" }, + { pid: 100, ppid: 9000, name: "zsh" }, + { pid: 9001, ppid: 1, name: "zsh" }, + { pid: 200, ppid: 9001, name: "node" }, + { pid: 9002, ppid: 1, name: "zsh" }, + { pid: 300, ppid: 9002, name: "zsh" }, + { pid: 301, ppid: 300, name: "sleep" }, + { pid: 9003, ppid: 1, name: "zsh" }, + ]), + }).pipe(Effect.provide(layerWithHostPlatform("linux"))); + yield* manager.open(openInput({ terminalId: "idle" })); + yield* manager.open(openInput({ terminalId: "dev-server" })); + yield* manager.open(openInput({ terminalId: "subshell" })); + yield* manager.open(openInput({ threadId: "thread-2" })); - yield* manager.write({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - data: "ls\n", - }); - yield* manager.resize({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cols: 120, - rows: 30, - }); + yield* manager.closeIdle({ threadId: "thread-1" }); - expect(process.writes).toEqual(["ls\n"]); - expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); + expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([ + true, + false, + false, + false, + ]); }), ); - it.effect("preserves structured context and causes for PTY I/O failures", () => + it.effect("keeps terminals that get input or output while closeIdle checks them", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const writeCause = new Error("PTY input handle is unavailable"); - process.writeFailure = writeCause; - const writeError = yield* Effect.flip( - manager.write({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - data: "secret input that must not be attached to the error", - }), - ); - - expect(writeError).toMatchObject({ - _tag: "TerminalWriteError", - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - terminalPid: process.pid, + const ptyAdapter = new FakePtyAdapter(); + let duringCheck: (pid: number) => Effect.Effect = () => Effect.void; + const { manager } = yield* createManager(5, { + ptyAdapter, + subprocessPollIntervalMs: 60_000, + subprocessInspector: (pid) => + duringCheck(pid).pipe( + Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }), + ), }); - expect(writeError.cause).toBe(writeCause); - expect(writeError).not.toHaveProperty("data"); - - const resizeCause = new Error("PTY resize handle is unavailable"); - process.resizeFailure = resizeCause; - const resizeError = yield* Effect.flip( - manager.resize({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cols: 132, - rows: 40, - }), + const outputSeen = yield* Deferred.make(); + const unsubscribeOutput = yield* manager.subscribe((event) => + event.type === "output" + ? Deferred.succeed(outputSeen, undefined).pipe(Effect.asVoid) + : Effect.void, ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeOutput)); + yield* manager.open(openInput({ terminalId: "typed" })); + yield* manager.open(openInput({ terminalId: "echoed" })); + const [typed, echoed] = ptyAdapter.processes; + duringCheck = (pid) => + pid === typed!.pid + ? manager + .write({ threadId: "thread-1", terminalId: "typed", data: "make build\r" }) + .pipe(Effect.orDie) + : Effect.gen(function* () { + echoed!.emitData("make build\r\n"); + yield* Deferred.await(outputSeen); + }).pipe(Effect.orDie); - expect(resizeError).toMatchObject({ - _tag: "TerminalResizeError", - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - terminalPid: process.pid, - cols: 132, - rows: 40, - }); - expect(resizeError.cause).toBe(resizeCause); + yield* manager.closeIdle({ threadId: "thread-1" }); - process.resizeFailure = undefined; - yield* manager.open(openInput({ cols: 132, rows: 40 })); - expect(process.resizeCalls).toEqual([{ cols: 132, rows: 40 }]); + expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([false, false]); }), ); - it.effect("ignores delayed resize requests after a terminal closes", () => + it.effect("caps incrementally appended history without losing partial or empty lines", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); + const { manager, ptyAdapter } = yield* createManager(3); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; - yield* manager.close({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - deleteHistory: true, - }); - yield* manager.resize({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cols: 120, - rows: 30, - }); + process.emitData("line1\n"); + process.emitData("\n"); + process.emitData("line3"); + process.emitData("-continued\nline4"); + yield* manager.close({ threadId: "thread-1" }); - expect(process.resizeCalls).toEqual([]); + const reopened = yield* manager.open(openInput()); + expect(reopened.history).toBe("\nline3-continued\nline4"); }), ); - it.effect("resizes running terminal on open when a different size is requested", () => + it.effect("bounds persisted and attached history without truncating live output", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput({ cols: 100, rows: 24 })); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const reopened = yield* manager.open(openInput({ cols: 120, rows: 30 })); - - assert.equal(reopened.status, "running"); - expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); - }), - ); - - it.effect("supports multiple terminals per thread independently", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput({ terminalId: "default" })); - yield* manager.open(openInput({ terminalId: "term-2" })); - - const first = ptyAdapter.processes[0]; - const second = ptyAdapter.processes[1]; - expect(first).toBeDefined(); - expect(second).toBeDefined(); - if (!first || !second) return; - - yield* manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); - yield* manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); - - expect(first.writes).toEqual(["pwd\n"]); - expect(second.writes).toEqual(["ls\n"]); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - }), - ); - - it.effect("clears transcript and emits cleared event", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); - const path = yield* Path.Path; - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("hello\n"); - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(readFileString), - Effect.map((text) => text === ""), - ), - ); - - const events = yield* getEvents; - expect(events.some((event) => event.type === "cleared")).toBe(true); - expect( - events.some( - (event) => - event.type === "cleared" && - event.threadId === "thread-1" && - event.terminalId === DEFAULT_TERMINAL_ID, - ), - ).toBe(true); - }), - ); - - it.effect("restarts terminal with empty transcript and respawns pty", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir } = yield* createManager(); - yield* manager.open(openInput()); - const firstProcess = ptyAdapter.processes[0]; - expect(firstProcess).toBeDefined(); - if (!firstProcess) return; - firstProcess.emitData("before restart\n"); - const path = yield* Path.Path; - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - - const snapshot = yield* manager.restart(restartInput()); - assert.equal(snapshot.history, ""); - assert.equal(snapshot.status, "running"); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(readFileString), - Effect.map((text) => text === ""), - ), - ); - }), - ); - - it.effect("restarts a running session when open is called with a different cwd", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, baseDir } = yield* createManager(); - const path = yield* Path.Path; - const originalCwd = path.join(baseDir, "original"); - const differentCwd = path.join(baseDir, "different"); - yield* makeDirectory(originalCwd); - yield* makeDirectory(differentCwd); - - yield* manager.open(openInput({ cwd: originalCwd })); - const firstProcess = ptyAdapter.processes[0]; - expect(firstProcess).toBeDefined(); - if (!firstProcess) return; - - firstProcess.emitData("before reopen\n"); - const logPath = yield* historyLogPath(logsDir); - yield* waitFor(pathExists(logPath)); - - const reopened = yield* manager.open(openInput({ cwd: differentCwd })); - - expect(ptyAdapter.spawnInputs).toHaveLength(2); - assert.equal(firstProcess.killed, true); - assert.equal(reopened.cwd, differentCwd); - assert.equal(reopened.history, ""); - yield* waitFor(Effect.map(readFileString(logPath), (text) => text === "")); - }), - ); - - it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => - Effect.gen(function* () { - const { manager, getEvents, baseDir } = yield* createManager(); - const path = yield* Path.Path; - const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a"); - const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b"); - yield* makeDirectory(firstWorktreePath); - yield* makeDirectory(secondWorktreePath); - const startedSnapshot = yield* manager.open( - openInput({ - cwd: firstWorktreePath, - worktreePath: firstWorktreePath, - }), - ); - const restartedSnapshot = yield* manager.restart( - restartInput({ - cwd: secondWorktreePath, - worktreePath: secondWorktreePath, - }), - ); - - assert.equal(startedSnapshot.worktreePath, firstWorktreePath); - assert.equal(restartedSnapshot.worktreePath, secondWorktreePath); - - const events = yield* getEvents; - const startedEvent = events.find( - (event): event is Extract => event.type === "started", - ); - const restartedEvent = events.find( - (event): event is Extract => - event.type === "restarted", - ); - - assert.equal(startedEvent?.snapshot.worktreePath, firstWorktreePath); - assert.equal(restartedEvent?.snapshot.worktreePath, secondWorktreePath); - }), - ); - - it.effect("preserves worktree metadata when reopening an exited session", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager(); - const path = yield* Path.Path; - const worktreePath = path.join(baseDir, "worktrees", "feature-a"); - yield* makeDirectory(worktreePath); - - yield* manager.open( - openInput({ - cwd: worktreePath, - worktreePath, - }), - ); - - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), - ); - - const reopenedSnapshot = yield* manager.open( - openInput({ - cwd: worktreePath, - worktreePath, - }), - ); - - assert.equal(reopenedSnapshot.worktreePath, worktreePath); - - const events = yield* getEvents; - const reopenedEvent = events - .toReversed() - .find( - (event): event is Extract => event.type === "started", - ); - - assert.equal(reopenedEvent?.snapshot.worktreePath, worktreePath); - }), - ); - - it.effect("emits exited event and reopens with clean transcript after exit", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); - const path = yield* Path.Path; - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - process.emitData("old data\n"); - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), - ); - const reopened = yield* manager.open(openInput()); - - assert.equal(reopened.history, ""); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - expect( - yield* historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(readFileString), - ), - ).toBe(""); - }), - ); - - it.effect("ignores trailing writes after terminal exit", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* manager.write({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - data: "\r", - }); - expect(process.writes).toEqual([]); - }), - ); - - it.effect("emits subprocess activity events when child-process state changes", () => - Effect.gen(function* () { - let inspect: { - readonly hasRunningSubprocess: boolean; - readonly childCommand: string | null; - readonly processIds: ReadonlyArray; - } = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - const { manager, getEvents } = yield* createManager(5, { - subprocessInspector: () => Effect.succeed(inspect), - subprocessPollIntervalMs: 20, - }); - - yield* manager.open(openInput()); - expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); - - inspect = { hasRunningSubprocess: true, childCommand: "vim", processIds: [100, 101] }; - yield* waitFor( - Effect.map(getEvents, (events) => - events.some( - (event) => - event.type === "activity" && - event.hasRunningSubprocess === true && - event.label === "vim", - ), - ), - "1200 millis", - ); - - inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - yield* waitFor( - Effect.map(getEvents, (events) => - events.some( - (event) => - event.type === "activity" && - event.hasRunningSubprocess === false && - event.label === "Terminal 1", - ), - ), - "1200 millis", - ); - }), - ); - - it.effect("does not invoke subprocess polling until a terminal session is running", () => - Effect.gen(function* () { - let checks = 0; - const { manager } = yield* createManager(5, { - subprocessInspector: () => { - checks += 1; - return Effect.succeed({ - hasRunningSubprocess: false, - childCommand: null, - processIds: [], - }); - }, - subprocessPollIntervalMs: 20, - }); - - yield* Effect.sleep("80 millis"); - assert.equal(checks, 0); - - yield* manager.open(openInput()); - yield* waitFor( - Effect.sync(() => checks > 0), - "1200 millis", - ); - }), - ); - - it.effect("derives subprocess activity for every terminal from one shared process snapshot", () => - Effect.gen(function* () { - const runCalls: Array<{ command: string; args: ReadonlyArray }> = []; - const psStdout = [" 100 9000 vim", " 101 100 git", " 200 9001 /usr/bin/python3"].join( - "\n", - ); - const processRunner: ProcessRunner.ProcessRunner["Service"] = { - run: (input) => - Effect.sync(() => { - runCalls.push({ command: input.command, args: input.args }); - return { - stdout: psStdout, - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - }), - }; - - const { manager, getEvents } = yield* createManager(5, { - subprocessPollIntervalMs: 20, - }).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - Effect.provide(layerWithHostPlatform("linux")), - ); - - yield* manager.open(openInput()); - yield* manager.open(openInput({ threadId: "thread-2" })); - - yield* waitFor( - Effect.map( - getEvents, - (events) => - events.some( - (event) => - event.type === "activity" && - event.hasRunningSubprocess === true && - event.label === "vim", - ) && - events.some( - (event) => - event.type === "activity" && - event.hasRunningSubprocess === true && - event.label === "python3", - ), - ), - "1200 millis", - ); - yield* waitFor( - Effect.sync(() => runCalls.length >= 3), - "1200 millis", - ); - - expect(runCalls.every((call) => call.args.join(" ") === "-eo pid=,ppid=,comm=")).toBe(true); - }), - ); - - it.effect("keeps last known subprocess state when the process snapshot fails", () => - Effect.gen(function* () { - let failSnapshots = false; - let failedCalls = 0; - const processRunner: ProcessRunner.ProcessRunner["Service"] = { - run: () => - Effect.sync(() => { - if (failSnapshots) failedCalls += 1; - return { - stdout: failSnapshots ? "" : " 100 9000 vim", - stderr: "", - code: ChildProcessSpawner.ExitCode(failSnapshots ? 1 : 0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - }), - }; - - const { manager, getEvents } = yield* createManager(5, { - subprocessPollIntervalMs: 20, - }).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - Effect.provide(layerWithHostPlatform("linux")), - ); - - yield* manager.open(openInput()); - yield* waitFor( - Effect.map(getEvents, (events) => - events.some( - (event) => - event.type === "activity" && - event.hasRunningSubprocess === true && - event.label === "vim", - ), - ), - "1200 millis", - ); - - failSnapshots = true; - yield* waitFor( - Effect.sync(() => failedCalls >= 3), - "1200 millis", - ); - - const activityEvents = (yield* getEvents).filter((event) => event.type === "activity"); - expect(activityEvents.length).toBeGreaterThan(0); - expect(activityEvents.every((event) => event.hasRunningSubprocess === true)).toBe(true); - }), - ); - - it("calculates snapshot failure backoff and success reset delays", () => { - assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 0), 1_000); - assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 1), 2_000); - assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 2), 4_000); - assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 30), 60_000); - }); - - it.effect("uses process snapshots from the resource monitor", () => - Effect.gen(function* () { - let snapshotCalls = 0; - const { manager, getEvents } = yield* createManager(5, { - subprocessPollIntervalMs: 20, - processTable: Effect.sync(() => { - snapshotCalls += 1; - return [{ pid: 100, ppid: 9000, name: "ping.exe" }]; - }), - }).pipe(Effect.provide(layerWithHostPlatform("win32"))); - - yield* manager.open(openInput()); - yield* waitFor( - Effect.map(getEvents, (events) => - events.some( - (event) => - event.type === "activity" && event.hasRunningSubprocess && event.label === "ping", - ), - ), - "1200 millis", - ); - expect(snapshotCalls).toBeGreaterThan(0); - }), - ); - - it.effect("closes only a thread's idle shells, ignoring a helper forked from the shell", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - processTable: Effect.succeed([ - { pid: 9000, ppid: 1, name: "zsh" }, - { pid: 100, ppid: 9000, name: "zsh" }, - { pid: 9001, ppid: 1, name: "zsh" }, - { pid: 200, ppid: 9001, name: "node" }, - { pid: 9002, ppid: 1, name: "zsh" }, - { pid: 300, ppid: 9002, name: "zsh" }, - { pid: 301, ppid: 300, name: "sleep" }, - { pid: 9003, ppid: 1, name: "zsh" }, - ]), - }).pipe(Effect.provide(layerWithHostPlatform("linux"))); - yield* manager.open(openInput({ terminalId: "idle" })); - yield* manager.open(openInput({ terminalId: "dev-server" })); - yield* manager.open(openInput({ terminalId: "subshell" })); - yield* manager.open(openInput({ threadId: "thread-2" })); - - yield* manager.closeIdle({ threadId: "thread-1" }); - - expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([ - true, - false, - false, - false, - ]); - }), - ); - - it.effect("keeps terminals that get input or output while closeIdle checks them", () => - Effect.gen(function* () { - const ptyAdapter = new FakePtyAdapter(); - let duringCheck: (pid: number) => Effect.Effect = () => Effect.void; - const { manager, getEvents } = yield* createManager(5, { - ptyAdapter, - subprocessPollIntervalMs: 60_000, - subprocessInspector: (pid) => - duringCheck(pid).pipe( - Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }), - ), - }); - yield* manager.open(openInput({ terminalId: "typed" })); - yield* manager.open(openInput({ terminalId: "echoed" })); - const [typed, echoed] = ptyAdapter.processes; - duringCheck = (pid) => - pid === typed!.pid - ? manager - .write({ threadId: "thread-1", terminalId: "typed", data: "make build\r" }) - .pipe(Effect.orDie) - : Effect.gen(function* () { - echoed!.emitData("make build\r\n"); - yield* waitFor( - Effect.map(getEvents, (events) => events.some((event) => event.type === "output")), - ); - }).pipe(Effect.orDie); - - yield* manager.closeIdle({ threadId: "thread-1" }); - - expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([false, false]); - }), - ); - - it.effect("backs off the spawned fallback when the resource monitor snapshot fails", () => - Effect.gen(function* () { - const fallbackCalls: Array = []; - const processRunner: ProcessRunner.ProcessRunner["Service"] = { - run: () => - Clock.currentTimeMillis.pipe( - Effect.map((now) => { - fallbackCalls.push(now); - return { - stdout: " 100 9000 vim", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrInvalidUtf8: false, - stdoutInvalidUtf8: false, - stderrTruncated: false, - }; - }), - ), - }; - - const { manager, getEvents } = yield* createManager(5, { - subprocessPollIntervalMs: 20, - processTable: Effect.fail("sidecar unavailable").pipe( - Effect.mapError((cause) => cause as never), - ), - }).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - Effect.provide(layerWithHostPlatform("linux")), - ); - - yield* manager.open(openInput()); - yield* waitFor( - Effect.map(getEvents, (events) => - events.some( - (event) => - event.type === "activity" && - event.hasRunningSubprocess === true && - event.label === "vim", - ), - ), - "1200 millis", - ); - - yield* waitFor( - Effect.sync(() => fallbackCalls.length >= 4), - "2000 millis", - ); - const spanMs = fallbackCalls[3]! - fallbackCalls[0]!; - expect(spanMs).toBeGreaterThan(150); - }), - ); - - it.effect("coalesces live history writes while retaining the latest output on close", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const writes = yield* Queue.unbounded(); - const output = yield* Queue.unbounded(); - const trackedFileSystem = FileSystem.FileSystem.of({ - ...fs, - writeFileString: (filePath, contents, options) => - fs - .writeFileString(filePath, contents, options) - .pipe( - Effect.tap(() => (contents.length > 0 ? Queue.offer(writes, contents) : Effect.void)), - ), - }); - const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 0 }).pipe( - Effect.provideService(FileSystem.FileSystem, trackedFileSystem), - ); - yield* manager.open(openInput()); - const unsubscribe = yield* manager.subscribe((event) => - event.type === "output" ? Queue.offer(output, event.data).pipe(Effect.asVoid) : Effect.void, - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - const process = ptyAdapter.processes[0]!; - - process.emitData("first"); - yield* Queue.take(output); - yield* TestClock.adjust("200 millis"); - expect(yield* Queue.size(writes)).toBe(0); - - process.emitData(" second"); - yield* Queue.take(output); - yield* TestClock.adjust("50 millis"); - expect(yield* Queue.take(writes)).toBe("first second"); - - process.emitData(" third"); - yield* Queue.take(output); - const close = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust("250 millis"); - yield* Fiber.join(close); - expect((yield* manager.open(openInput())).history).toBe("first second third"); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("caps persisted history to configured line limit", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(3); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("line1\nline2\nline3\nline4\n"); - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); - expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); - }), - ); - - it.effect("caps incrementally appended history without losing partial or empty lines", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(3); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("line1\n"); - process.emitData("\n"); - process.emitData("line3"); - process.emitData("-continued\nline4"); - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - expect(reopened.history).toBe("\nline3-continued\nline4"); - }), - ); - - it.effect("bounds persisted and attached history without truncating live output", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir } = yield* createManager(5, { historyByteLimit: 10 }); - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream(openInput(), (event) => - Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - const writes = ["a".repeat(32), "😀\rEND"]; - const process = ptyAdapter.processes[0]!; - for (const text of writes) process.emitData(text); - yield* manager.close({ threadId: "thread-1" }); - expect(yield* readFileString(yield* historyLogPath(logsDir))).toBe("aa😀\rEND"); + const { manager, ptyAdapter, logsDir } = yield* createManager(5, { historyByteLimit: 10 }); + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream(openInput(), (event) => + Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + const writes = ["a".repeat(32), "😀\rEND"]; + const process = ptyAdapter.processes[0]!; + for (const text of writes) process.emitData(text); + yield* manager.close({ threadId: "thread-1" }); + expect(yield* readFileString(yield* historyLogPath(logsDir))).toBe("aa😀\rEND"); const reopened = yield* manager.open(openInput()); const events = yield* Ref.get(attachEvents); expect(events.filter((event) => event.type === "output").map((event) => event.data)).toEqual( writes, ); - const snapshot = events.filter((event) => event.type === "snapshot").at(-1)?.snapshot; - expect(snapshot?.history).toBe("aa😀\rEND"); - expect(snapshot?.sequence).toBe(reopened.sequence); - }), - ); - - it.effect.each(["current", "legacy"] as const)( - "reads only a Unicode-safe tail from oversized %s history", - (source) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - let sourcePath: string | undefined; - let closedReads = 0; - const readRequests: number[] = []; - const trackedFileSystem = FileSystem.FileSystem.of({ - ...fs, - readFileString: (candidate, encoding) => - candidate === sourcePath - ? Effect.die("History restoration must not read the whole file") - : fs.readFileString(candidate, encoding), - open: (candidate, options) => - Effect.gen(function* () { - if (candidate !== sourcePath) return yield* fs.open(candidate, options); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - closedReads += 1; - }), - ); - const file = yield* fs.open(candidate, options); - return new Proxy(file, { - get(target, key) { - if (key === "read") { - return (buffer: Uint8Array) => { - readRequests.push(buffer.byteLength); - return target.read(buffer.subarray(0, 5)); - }; - } - return Reflect.get(target, key, target); - }, - }); - }), - }); - const { manager, logsDir } = yield* createManager(5, { historyByteLimit: 15 }).pipe( - Effect.provideService(FileSystem.FileSystem, trackedFileSystem), - ); - const nextPath = yield* historyLogPath(logsDir); - sourcePath = source === "current" ? nextPath : path.join(logsDir, "thread-1.log"); - yield* fs.writeFileString(sourcePath, "old".repeat(32_768) + "😀\uFEFFnewest\ré"); - - const snapshot = yield* manager.open(openInput()); - expect(snapshot.history).toBe("\uFEFFnewest\ré"); - expect(readRequests).toEqual([15, 10, 5]); - expect(closedReads).toBe(1); - expect(Buffer.from(yield* fs.readFile(nextPath)).toString()).toBe("\uFEFFnewest\ré"); - if (source === "legacy") expect(yield* fs.exists(sourcePath)).toBe(false); - yield* manager.close({ threadId: "thread-1" }); - expect((yield* manager.open(openInput())).history).toBe("\uFEFFnewest\ré"); - }), - ); - - it.effect("retains plain Unicode and control characters around a split query", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]!; - const before = "café\t界🙂\u0007\u009c\u0085"; - const after = "e\u0301\r\n"; - - process.emitData(before); - process.emitData("\u001b[5"); - process.emitData(`n${after}`); - yield* manager.close({ threadId: "thread-1" }); - - expect((yield* manager.open(openInput())).history).toBe(before + after); - }), - ); - - it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("prompt "); - process.emitData("\u001b[32mok\u001b[0m "); - process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007"); - process.emitData("\u001b[1;1R"); - process.emitData("done\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - assert.equal(reopened.history, "prompt \u001b[32mok\u001b[0m done\n"); - }), - ); - - it.effect("strips replayable CSI and DCS traffic while preserving setters", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("prompt "); - process.emitData("\u001b[?2026$p\u001b[?2026;2$y\u001b[>q\u001b[?u\u001b[?31u"); - process.emitData("\u001bP$q m\u001b\\\u001bP1$r0m\u001b\\"); - process.emitData("\u001bP+q544e\u001b\\\u001bP1+r544e=1b\u001b\\"); - process.emitData("\u0090$q m\u009c\u00901$r0m\u009c"); - process.emitData("\u0090+q544e\u009c\u00901+r544e=1b\u009c"); - process.emitData('\u001b[!p\u001b["p\u001b[4 q\u001b[u'); - process.emitData("done\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - assert.equal(reopened.history, 'prompt \u001b[!p\u001b["p\u001b[4 q\u001b[udone\n'); - }), - ); - - it.effect("handles CSI and DCS query sequences split across output chunks", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("before "); - process.emitData("\u001b[?2026$"); - process.emitData("pafter "); - process.emitData("\u001bP$q "); - process.emitData("m\u001b"); - process.emitData("\\after "); - process.emitData("\u009b?3"); - process.emitData("1uafter "); - process.emitData("\u0090+q544e"); - process.emitData("\u009cafter\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - assert.equal(reopened.history, "before after after after after\n"); - }), - ); - - it.effect( - "preserves clear and style control sequences while dropping chunk-split query traffic", - () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("before clear\n"); - process.emitData("\u001b[H\u001b[2J"); - process.emitData("prompt "); - process.emitData("\u001b]11;"); - process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1"); - process.emitData("R\u001b[36mdone\u001b[0m\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - assert.equal( - reopened.history, - "before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n", - ); - }), - ); - - it.effect("does not leak final bytes from ESC sequences with intermediate bytes", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("before "); - process.emitData("\u001b(B"); - process.emitData("after\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - assert.equal(reopened.history, "before \u001b(Bafter\n"); - }), - ); - - it.effect( - "preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes", - () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("before "); - process.emitData("\u001b("); - process.emitData("Bafter\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - assert.equal(reopened.history, "before \u001b(Bafter\n"); - }), - ); - - it.effect("deletes history file when close(deleteHistory=true)", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - process.emitData("bye\n"); - const path = yield* Path.Path; - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - - yield* manager.close({ threadId: "thread-1", deleteHistory: true }); - expect( - yield* historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ).toBe(false); - }), - ); - - it.effect("closes all terminals for a thread when close omits terminalId", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir } = yield* createManager(); - yield* manager.open(openInput({ terminalId: "default" })); - yield* manager.open(openInput({ terminalId: "sidecar" })); - const defaultProcess = ptyAdapter.processes[0]; - const sidecarProcess = ptyAdapter.processes[1]; - expect(defaultProcess).toBeDefined(); - expect(sidecarProcess).toBeDefined(); - if (!defaultProcess || !sidecarProcess) return; - - defaultProcess.emitData("default\n"); - sidecarProcess.emitData("sidecar\n"); - const path = yield* Path.Path; - yield* waitFor( - multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - yield* waitFor( - multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - - yield* manager.close({ threadId: "thread-1", deleteHistory: true }); - - assert.equal(defaultProcess.killed, true); - assert.equal(sidecarProcess.killed, true); - expect( - yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ).toBe(false); - expect( - yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ).toBe(false); + const snapshot = events.filter((event) => event.type === "snapshot").at(-1)?.snapshot; + expect(snapshot?.history).toBe("aa😀\rEND"); + expect(snapshot?.sequence).toBe(reopened.sequence); }), ); - it.effect("escalates terminal shutdown to SIGKILL when process does not exit in time", () => + it.effect("retains plain Unicode and control characters around a split query", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 }); + const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; + const process = ptyAdapter.processes[0]!; + const before = "café\t界🙂\u0007\u009c\u0085"; + const after = "e\u0301\r\n"; - const closeFiber = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust("10 millis"); - yield* Fiber.join(closeFiber); + process.emitData(before); + process.emitData("\u001b[5"); + process.emitData(`n${after}`); + yield* manager.close({ threadId: "thread-1" }); - assert.equal(process.killSignals[0], "SIGTERM"); - expect(process.killSignals).toContain("SIGKILL"); - }).pipe(Effect.provide(TestClock.layer())), + expect((yield* manager.open(openInput())).history).toBe(before + after); + }), ); - it.effect("skips SIGKILL when the process exits during the grace period", () => + it.effect("strips replayable CSI and DCS traffic while preserving setters", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10_000 }); + const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; - const sigterm = yield* Deferred.make(); - process.onKill = (signal) => { - if (signal === "SIGTERM") Deferred.doneUnsafe(sigterm, Effect.void); - }; + process.emitData("prompt "); + process.emitData("\u001b[?2026$p\u001b[?2026;2$y\u001b[>q\u001b[?u\u001b[?31u"); + process.emitData("\u001bP$q m\u001b\\\u001bP1$r0m\u001b\\"); + process.emitData("\u001bP+q544e\u001b\\\u001bP1+r544e=1b\u001b\\"); + process.emitData("\u0090$q m\u009c\u00901$r0m\u009c"); + process.emitData("\u0090+q544e\u009c\u00901+r544e=1b\u009c"); + process.emitData('\u001b[!p\u001b["p\u001b[4 q\u001b[u'); + process.emitData("done\n"); yield* manager.close({ threadId: "thread-1" }); - yield* Deferred.await(sigterm); - process.emitExit({ exitCode: 0, signal: 15 }); - yield* TestClock.adjust("10 seconds"); - - assert.deepEqual(process.killSignals, ["SIGTERM"]); - }).pipe(Effect.provide(TestClock.layer())), - ); - it.effect("does not retain a kill fiber that finishes before registration", () => - Effect.gen(function* () { - let managerState: - | SynchronizedRef.SynchronizedRef<{ - killFibers: Map>; - }> - | undefined; - const originalMake = (value: A) => Effect.sync(() => SynchronizedRef.makeUnsafe(value)); - const makeSpy = vi.spyOn(SynchronizedRef, "make").mockImplementation((value) => - originalMake(value).pipe( - Effect.map((ref) => { - if (typeof value === "object" && value !== null && "killFibers" in value) { - managerState = ref as typeof managerState; - } - return ref; - }), - ), + const reopened = yield* manager.open(openInput()); + expect(reopened.history.startsWith('prompt \u001b[!p\u001b["p\u001b[4 q\u001b[udone\n')).toBe( + true, ); - yield* Effect.addFinalizer(() => Effect.sync(() => makeSpy.mockRestore())); - for (const budget of [10, 15, 20, 25, 30, 40, 50, 75, 100, 150, 200, 500]) { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]!; - process.onKill = () => process.emitExit({ exitCode: 0, signal: 15 }); - // Vary the caller's operation budget to cover child completion before - // and after the parent resumes its registration step. - yield* manager - .close({ threadId: "thread-1" }) - .pipe(Effect.provideService(References.MaxOpsBeforeYield, budget)); - yield* Effect.yieldNow; - expect(managerState).toBeDefined(); - yield* Fiber.awaitAll(SynchronizedRef.getUnsafe(managerState!).killFibers.values()); - expect(SynchronizedRef.getUnsafe(managerState!).killFibers.size, `budget ${budget}`).toBe( - 0, - ); - expect(process.killSignals).toEqual(["SIGTERM"]); - } }), ); - it.effect("releases the exit listener when kill escalation is interrupted", () => + it.effect("handles CSI and DCS query sequences split across output chunks", () => Effect.gen(function* () { - const scope = yield* Scope.make("sequential"); - const { manager, ptyAdapter } = yield* createManager(5, { - processKillGraceMs: 10_000, - }).pipe(Effect.provideService(Scope.Scope, scope)); + const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; - const sigterm = yield* Deferred.make(); - process.onKill = (signal) => { - if (signal === "SIGTERM") Deferred.doneUnsafe(sigterm, Effect.void); - }; - yield* manager.close({ threadId: "thread-1" }); - yield* Deferred.await(sigterm); - assert.isAbove(process.exitListenerCount, 0); + process.emitData("before "); + process.emitData("\u001b[?2026$"); + process.emitData("pafter "); + process.emitData("\u001bP$q "); + process.emitData("m\u001b"); + process.emitData("\\after "); + process.emitData("\u009b?3"); + process.emitData("1uafter "); + process.emitData("\u0090+q544e"); + process.emitData("\u009cafter\n"); - yield* Scope.close(scope, Exit.void); + yield* manager.close({ threadId: "thread-1" }); - assert.equal(process.exitListenerCount, 0); - assert.deepEqual(process.killSignals, ["SIGTERM"]); - }).pipe(Effect.provide(TestClock.layer())), + const reopened = yield* manager.open(openInput()); + assert.equal(reopened.history, "before after after after after\n"); + }), ); - it.effect("publishes closed events when terminals are explicitly closed", () => - Effect.gen(function* () { - const { manager, getEvents } = yield* createManager(); - yield* manager.open(openInput({ terminalId: "default" })); - yield* manager.open(openInput({ terminalId: "sidecar" })); + it.effect( + "preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes", + () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; - yield* manager.close({ threadId: "thread-1" }); + process.emitData("before "); + process.emitData("\u001b("); + process.emitData("Bafter\n"); - const closedEvents = (yield* getEvents).filter( - (event): event is Extract => event.type === "closed", - ); - expect(closedEvents.map((event) => event.terminalId).sort()).toEqual(["default", "sidecar"]); - }), + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + expect(reopened.history.startsWith("before \u001b(Bafter\n")).toBe(true); + }), ); - it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () => + it.effect.each([ + { + label: "subagent", + threadId: + "thread:provider:claudeAgent:native-thread:provider-thread%3Aprovider%3AclaudeAgent%3Anative-thread%3Apending%253Arun%253Athread%253A9175fb53-52d7-4d4d-9be7-25a76cf84b91%253Aordinal%253A7%3Aad899ae84c7a131b0", + }, + // Also too long for the legacy `.log` lookup. + { label: "300-character", threadId: "x".repeat(300) }, + ])("persists history for a $label thread id too long to encode into a filename", ({ threadId }) => Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, { - maxRetainedInactiveSessions: 1, - }); + const terminalId = "t".repeat(128); + const { manager, ptyAdapter, logsDir } = yield* createManager(); + const fs = yield* FileSystem.FileSystem; + yield* manager.open(openInput({ threadId })); + yield* manager.open(openInput({ threadId, terminalId })); + ptyAdapter.processes[0]!.emitData("default\n"); + ptyAdapter.processes[1]!.emitData("long\n"); + yield* manager.close({ threadId }); - yield* manager.open(openInput({ threadId: "thread-1" })); - yield* manager.open(openInput({ threadId: "thread-2" })); + expect((yield* manager.open(openInput({ threadId }))).history).toBe("default\n"); + expect((yield* manager.open(openInput({ threadId, terminalId }))).history).toBe("long\n"); - const first = ptyAdapter.processes[0]; - const second = ptyAdapter.processes[1]; - expect(first).toBeDefined(); - expect(second).toBeDefined(); - if (!first || !second) return; + yield* manager.close({ threadId, deleteHistory: true }); + expect(yield* fs.readDirectory(logsDir)).toEqual([]); + }), + ); - first.emitData("first-history\n"); - second.emitData("second-history\n"); - const path = yield* Path.Path; - yield* waitFor( - historyLogPath(logsDir, "thread-1").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - first.emitExit({ exitCode: 0, signal: 0 }); - yield* Effect.sleep(Duration.millis(5)); - second.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map( - getEvents, - (events) => events.filter((event) => event.type === "exited").length === 2, - ), - ); + it.effect("keeps reading and deleting history at an encoded path that already fit", () => + Effect.gen(function* () { + const threadId = "x".repeat(120); + const { manager, logsDir } = yield* createManager(); + const fs = yield* FileSystem.FileSystem; + const logPath = yield* historyLogPath(logsDir, threadId); + yield* fs.makeDirectory(logsDir, { recursive: true }); + yield* fs.writeFileString(logPath, "before upgrade\n"); - const reopenedSecond = yield* manager.open(openInput({ threadId: "thread-2" })); - const reopenedFirst = yield* manager.open(openInput({ threadId: "thread-1" })); + expect((yield* manager.open(openInput({ threadId }))).history).toBe("before upgrade\n"); + // A long terminal id pushes only this terminal's name past the limit, so the thread's + // history is split across both forms and deleting the thread must remove both. + yield* manager.open(openInput({ threadId, terminalId: "t".repeat(128) })); - assert.equal(reopenedFirst.history, "first-history\n"); - assert.equal(reopenedSecond.history, ""); + yield* manager.close({ threadId, deleteHistory: true }); + expect(yield* fs.exists(logPath)).toBe(false); + expect(yield* fs.readDirectory(logsDir)).toEqual([]); }), ); @@ -2108,27 +925,6 @@ it.layer( }), ); - it.effect("prefers PowerShell over ComSpec for Windows terminals", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - env: { - ComSpec: "C:\\Windows\\System32\\cmd.exe", - PATH: "C:\\Windows\\System32", - SystemRoot: "C:\\Windows", - }, - }).pipe(Effect.provide(layerWithHostPlatform("win32"))); - - yield* manager.open(openInput()); - - expect(ptyAdapter.spawnInputs[0]).toEqual( - expect.objectContaining({ - shell: "pwsh.exe", - args: ["-NoLogo"], - }), - ); - }), - ); - it.effect("preserves Windows Path casing when appending managed ACP binaries", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -2222,32 +1018,6 @@ it.layer( }), ); - it.effect.each(["linux", "darwin", "win32"] as const)( - "advertises truecolor before the PTY backend on %s without replacing explicit values", - (platform) => - Effect.gen(function* () { - for (const [parentColor, runtimeColor, expected] of [ - [undefined, undefined, "truecolor"], - ["", undefined, "truecolor"], - ["24bit", undefined, "24bit"], - ["24bit", "", ""], - [undefined, "", ""], - ["24bit", "custom", "custom"], - ] as const) { - const env = Object.freeze({ COLORTERM: parentColor }); - const { manager, ptyAdapter } = yield* createManager(5, { - shellResolver: () => "/bin/sh", - env, - }).pipe(Effect.provide(layerWithHostPlatform(platform))); - yield* manager.open( - openInput({ env: runtimeColor === undefined ? {} : { COLORTERM: runtimeColor } }), - ); - expect(ptyAdapter.spawnInputs[0]?.env.COLORTERM).toBe(expected); - expect(env.COLORTERM).toBe(parentColor); - } - }), - ); - it.effect("turns off fish terminal queries unless the user chose query-term", () => Effect.gen(function* () { for (const [platform, env, runtimeEnv, expected] of [ @@ -2353,26 +1123,6 @@ it.layer( }), ); - it.effect("expands provider home paths passed to setup terminals", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5); - - yield* manager.open({ - ...openInput(), - env: { - CODEX_HOME: "~/.codex-work", - CLAUDE_CONFIG_DIR: "~/.claude-work", - CUSTOM_ACCOUNT: "~/leave-this-value-alone", - }, - }); - - const environment = ptyAdapter.spawnInputs[0]?.env; - expect(environment?.CODEX_HOME).toMatch(/[\\/][.]codex-work$/); - expect(environment?.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-work$/); - expect(environment?.CUSTOM_ACCOUNT).toBe("~/leave-this-value-alone"); - }), - ); - it.effect("strips AppImage runtime env from terminal sessions", () => Effect.gen(function* () { const appDir = "/tmp/.mount_T3Codeabc123"; @@ -2406,52 +1156,6 @@ it.layer( }), ); - it.effect("leaves the environment untouched when not launched from an AppImage", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - env: { - PATH: "/usr/local/bin:/usr/bin:/bin", - LD_LIBRARY_PATH: "/home/user/.local/lib", - OWD: "/home/user/keep-this", - }, - }); - yield* manager.open(openInput()); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - expect(spawnInput.env.PATH).toBe("/usr/local/bin:/usr/bin:/bin"); - expect(spawnInput.env.LD_LIBRARY_PATH).toBe("/home/user/.local/lib"); - expect(spawnInput.env.OWD).toBe("/home/user/keep-this"); - }), - ); - - it.effect("injects runtime env overrides into spawned terminals", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { env: { FORCE_COLOR: "3" } }); - yield* manager.open( - openInput({ - env: { - T3CODE_PROJECT_ROOT: "/repo", - T3CODE_WORKTREE_PATH: "/repo/worktree-a", - CUSTOM_FLAG: "1", - NO_COLOR: "1", - FORCE_COLOR: "0", - }, - }), - ); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - assert.equal(spawnInput.env.T3CODE_PROJECT_ROOT, "/repo"); - assert.equal(spawnInput.env.T3CODE_WORKTREE_PATH, "/repo/worktree-a"); - assert.equal(spawnInput.env.CUSTOM_FLAG, "1"); - assert.equal(spawnInput.env.NO_COLOR, "1"); - assert.equal(spawnInput.env.FORCE_COLOR, "0"); - }), - ); - it.effect("resolves a provider instance environment before spawning", () => Effect.gen(function* () { const providerInstanceId = ProviderInstanceId.make("codex_work"); @@ -2492,148 +1196,56 @@ it.layer( const error = yield* manager.open(openInput({ providerInstanceId })).pipe(Effect.flip); - assert.deepStrictEqual( - error, - new TerminalProviderInstanceNotFoundError({ providerInstanceId }), - ); - expect(ptyAdapter.spawnInputs).toHaveLength(0); - }), - ); - - it.effect("preserves the settings failure when provider environment resolution fails", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const providerInstanceId = ProviderInstanceId.make("codex_work"); - const settingsCause = new Error("secret store read failed"); - const settingsError = new ServerSettingsError({ - settingsPath: "/test/settings.json", - operation: "read-secret", - providerInstanceId, - environmentVariable: "OPENROUTER_API_KEY", - cause: settingsCause, - }); - const serverSettings = ServerSettings.ServerSettingsService.of({ - start: Effect.void, - ready: Effect.void, - getSettings: Effect.fail(settingsError), - updateSettings: () => Effect.fail(settingsError), - updateProviderInstance: () => Effect.fail(settingsError), - withSettingsSnapshot: () => Effect.fail(settingsError), - streamChanges: Stream.empty, - subscribeChanges: Effect.succeed(Stream.empty), - }); - - const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ - serverSettings, - path, - rawProviderInstanceId: providerInstanceId, - env: undefined, - }).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "TerminalProviderEnvironmentError", - providerInstanceId, - }); - expect(error.cause).toBe(settingsError); - expect(error.message).not.toContain(settingsError.message); - expect(error.message).not.toContain("OPENROUTER_API_KEY"); - }), - ); - - it.effect.each([ - { - name: "Codex home", - driver: "codex", - variable: "CODEX_HOME", - config: { homePath: "/configured/codex" }, - expectedHome: "/configured/codex", - }, - { - name: "Codex shadow home", - driver: "codex", - variable: "CODEX_HOME", - config: { homePath: "/configured/codex", shadowHomePath: "/configured/codex-shadow" }, - expectedHome: "/configured/codex-shadow", - }, - { - name: "Claude home", - driver: "claudeAgent", - variable: "CLAUDE_CONFIG_DIR", - config: { homePath: "/configured/claude" }, - expectedHome: "/configured/claude", - }, - ])("prefers $name over the instance environment", ({ driver, variable, config, expectedHome }) => - Effect.gen(function* () { - const path = yield* Path.Path; - const serverSettings = yield* ServerSettings.ServerSettingsService; - const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ - serverSettings, - path, - rawProviderInstanceId: "configured_home", - env: undefined, - }); - - expect(environment[variable]).toBe(path.resolve(expectedHome)); - }).pipe( - Effect.provide( - ServerSettings.layerTest({ - providerInstances: { - [ProviderInstanceId.make("configured_home")]: { - driver: ProviderDriverKind.make(driver), - environment: [{ name: variable, value: "~/.environment-account", sensitive: false }], - config, - }, - }, - }), - ), - ), - ); - - it.effect("resolves the Codex default slot", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const serverSettings = yield* ServerSettings.ServerSettingsService; - const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ - serverSettings, - path, - rawProviderInstanceId: "codex", - env: undefined, - }); - - expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-default$/); - }).pipe( - Effect.provide( - ServerSettings.ServerSettingsService.layerTest({ - providerInstances: { - [ProviderInstanceId.make("codex")]: { - driver: ProviderDriverKind.make("codex"), - config: { homePath: "~/.codex-default" }, - }, - }, - }), - ), - ), + assert.deepStrictEqual( + error, + new TerminalProviderInstanceNotFoundError({ providerInstanceId }), + ); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + }), ); - it.effect("resolves the Claude default slot", () => + it.effect.each([ + { + name: "Codex home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex" }, + expectedHome: "/configured/codex", + }, + { + name: "Codex shadow home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex", shadowHomePath: "/configured/codex-shadow" }, + expectedHome: "/configured/codex-shadow", + }, + { + name: "Claude home", + driver: "claudeAgent", + variable: "CLAUDE_CONFIG_DIR", + config: { homePath: "/configured/claude" }, + expectedHome: "/configured/claude", + }, + ])("prefers $name over the instance environment", ({ driver, variable, config, expectedHome }) => Effect.gen(function* () { const path = yield* Path.Path; const serverSettings = yield* ServerSettings.ServerSettingsService; const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ serverSettings, path, - rawProviderInstanceId: "claudeAgent", + rawProviderInstanceId: "configured_home", env: undefined, }); - expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-default$/); + expect(environment[variable]).toBe(path.resolve(expectedHome)); }).pipe( Effect.provide( - ServerSettings.ServerSettingsService.layerTest({ + ServerSettings.layerTest({ providerInstances: { - [ProviderInstanceId.make("claudeAgent")]: { - driver: ProviderDriverKind.make("claudeAgent"), - config: { homePath: "~/.claude-default" }, + [ProviderInstanceId.make("configured_home")]: { + driver: ProviderDriverKind.make(driver), + environment: [{ name: variable, value: "~/.environment-account", sensitive: false }], + config, }, }, }), @@ -2641,41 +1253,6 @@ it.layer( ), ); - it.effect("resolves an empty Codex default slot with default config", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const serverSettings = yield* ServerSettings.ServerSettingsService; - const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ - serverSettings, - path, - rawProviderInstanceId: "codex", - env: { CODEX_HOME: "/inherited/codex-home" }, - }); - - expect(environment.CODEX_HOME).toBe("/inherited/codex-home"); - }).pipe( - Effect.provide(ServerSettings.ServerSettingsService.layerTest({ providerInstances: {} })), - ), - ); - - it.effect("keeps unknown provider instance ids unavailable after default-slot hydration", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const serverSettings = yield* ServerSettings.ServerSettingsService; - const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ - serverSettings, - path, - rawProviderInstanceId: "codex_unknown", - env: undefined, - }).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "TerminalProviderInstanceNotFoundError", - providerInstanceId: "codex_unknown", - }); - }).pipe(Effect.provide(ServerSettings.ServerSettingsService.layerTest())), - ); - it.effect("restarts a running terminal when the resolved provider environment changes", () => Effect.gen(function* () { const providerInstanceId = ProviderInstanceId.make("codex_work"); @@ -2804,148 +1381,6 @@ it.layer( }), ); - it.effect("fails closed when attaching would create a missing provider terminal", () => - Effect.gen(function* () { - const providerInstanceId = ProviderInstanceId.make("deleted_instance"); - const { manager, ptyAdapter } = yield* createManager(5, { - resolveProviderInstanceEnvironment: (requestedId) => - Effect.fail( - new TerminalProviderInstanceNotFoundError({ - providerInstanceId: ProviderInstanceId.make(requestedId), - }), - ), - }); - - const error = yield* manager - .attachStream(openInput({ providerInstanceId }), () => Effect.void) - .pipe(Effect.flip); - - assert.deepStrictEqual( - error, - new TerminalProviderInstanceNotFoundError({ providerInstanceId }), - ); - expect(ptyAdapter.spawnInputs).toHaveLength(0); - }), - ); - - it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => - Effect.gen(function* () { - if ((yield* HostProcess.Platform) === "win32") return; - const { manager, ptyAdapter } = yield* createManager(5, { - shellResolver: () => "/bin/zsh", - }); - yield* manager.open(openInput()); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); - }), - ); - - it.effect("bridges PTY callbacks back into Effect-managed event streaming", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("hello from callback\n"); - - yield* waitFor( - Effect.map(getEvents, (events) => - events.some((event) => event.type === "output" && event.data === "hello from callback\n"), - ), - "1200 millis", - ); - }), - ); - - it.effect("pushes PTY callbacks to direct event subscribers", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - const subscriberEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.subscribe((event) => - Ref.update(subscriberEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("hello from subscriber\n"); - - yield* waitFor( - Effect.map(Ref.get(subscriberEvents), (events) => - events.some( - (event) => event.type === "output" && event.data === "hello from subscriber\n", - ), - ), - "1200 millis", - ); - }), - ); - - it.effect("subscribes terminal metadata with an initial snapshot and live deltas", () => - Effect.gen(function* () { - const { manager } = yield* createManager(); - yield* manager.open(openInput({ threadId: "existing-thread" })); - - const metadataEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.subscribeMetadata((event) => - Ref.update(metadataEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const initialEvents = yield* Ref.get(metadataEvents); - expect(initialEvents[0]).toMatchObject({ - type: "snapshot", - terminals: [ - { - threadId: "existing-thread", - terminalId: DEFAULT_TERMINAL_ID, - }, - ], - }); - - yield* manager.open(openInput({ threadId: "new-thread" })); - - yield* waitFor( - Effect.map(Ref.get(metadataEvents), (events) => - events.some( - (event) => - event.type === "upsert" && - event.terminal.threadId === "new-thread" && - event.terminal.terminalId === DEFAULT_TERMINAL_ID, - ), - ), - "1200 millis", - ); - - yield* manager.close({ threadId: "new-thread", terminalId: DEFAULT_TERMINAL_ID }); - - yield* waitFor( - Effect.map(Ref.get(metadataEvents), (events) => - events.some( - (event) => - event.type === "remove" && - event.threadId === "new-thread" && - event.terminalId === DEFAULT_TERMINAL_ID, - ), - ), - "1200 millis", - ); - }), - ); - it.effect("removes terminal metadata subscriptions when initial delivery fails", () => Effect.gen(function* () { const { manager } = yield* createManager(); @@ -2967,45 +1402,152 @@ it.layer( }), ); - it.effect( - "streams attach snapshots followed by live events without duplicate start snapshots", - () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream(openInput(), (event) => - Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; + it.effect("finishes shell startup when its creating attachment is cancelled", () => + Effect.gen(function* () { + const ptyAdapter = new GatedPtyAdapter(); + const { manager } = yield* createManager(5, { ptyAdapter }); + const input = openInput(); + const gate = { + spawning: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + ptyAdapter.gate = gate; + const consumer = yield* Stream.runDrain( + terminalOutputStream( + (listener, registerCleanup) => manager.attachStream(input, listener, registerCleanup), + (listener, registerCleanup) => manager.resyncStream(input, listener, registerCleanup), + ), + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(gate.spawning); + const cancelled = yield* Fiber.interrupt(consumer).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(gate.release, undefined); + yield* Fiber.join(cancelled); - expect(yield* Ref.get(attachEvents)).toMatchObject([ - { - type: "snapshot", - snapshot: { - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - }, - }, - ]); + const snapshots: TerminalAttachStreamEvent[] = []; + const stop = yield* manager.attachStream(input, (event) => + Effect.sync(() => { + snapshots.push(event); + }), + ); + stop(); + expect(snapshots).toMatchObject([ + { type: "snapshot", snapshot: { status: "running", pid: ptyAdapter.processes[0]?.pid } }, + ]); + expect(ptyAdapter.processes).toHaveLength(1); + }).pipe(Effect.scoped), + ); - process.emitData("hello from attach\n"); + it.effect("resyncs bounded history and follows close/reopen across a stalled stream", () => + Effect.gen(function* () { + const ptyAdapter = new GatedPtyAdapter(); + const historyByteLimit = 64 * 1024; + const { manager } = yield* createManager(5, { ptyAdapter, historyByteLimit }); + const input = openInput(); + const pull = yield* Stream.toPull( + terminalOutputStream( + (listener, registerCleanup) => manager.attachStream(input, listener, registerCleanup), + (listener, registerCleanup) => manager.resyncStream(input, listener, registerCleanup), + ), + ); + expect(yield* pull).toMatchObject([{ type: "snapshot", snapshot: { history: "" } }]); + const process = ptyAdapter.processes[0]!; + yield* publishPastOutputBudget(manager, process); + + const resumed = yield* pull; + expect(resumed).toHaveLength(1); + expect(resumed[0]?.type).toBe("snapshot"); + if (resumed[0]?.type !== "snapshot") throw new Error("Expected a recovery snapshot"); + expect(Buffer.byteLength(resumed[0].snapshot.history)).toBeLessThanOrEqual(historyByteLimit); + expect(resumed[0].snapshot.history.endsWith("last\n")).toBe(true); + process.emitData("after recovery\n"); + expect(yield* pull).toMatchObject([{ type: "output", data: "after recovery\n" }]); + expect(ptyAdapter.spawnInputs).toHaveLength(1); - yield* waitFor( - Effect.map(Ref.get(attachEvents), (events) => - events.some((event) => event.type === "output" && event.data === "hello from attach\n"), - ), - "1200 millis", - ); + yield* publishPastOutputBudget(manager, process); + yield* manager.close(input); + expect(yield* pull).toEqual([ + { type: "closed", threadId: input.threadId, terminalId: input.terminalId }, + ]); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + yield* manager.open(input); + expect(yield* pull).toMatchObject([ + { type: "snapshot", snapshot: { status: "running", pid: ptyAdapter.processes[1]!.pid } }, + ]); + const reopenedProcess = ptyAdapter.processes[1]!; + reopenedProcess.emitData("after reopen\n"); + expect(yield* pull).toMatchObject([{ type: "output", data: "after reopen\n" }]); + + yield* publishPastOutputBudget(manager, reopenedProcess); + // Queue close/reopen ahead of recovery while another spawn owns the thread lock. + const gate = { + spawning: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + ptyAdapter.gate = gate; + const holder = yield* manager + .open(openInput({ terminalId: "other" })) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(gate.spawning); + const closed = yield* manager.close(input).pipe(Effect.forkChild({ startImmediately: true })); + const reopened = yield* manager + .open(input) + .pipe(Effect.forkChild({ startImmediately: true })); + const recovery = yield* pull.pipe(Effect.forkChild({ startImmediately: true })); + reopenedProcess.emitData("old session\n"); + yield* Deferred.succeed(gate.release, undefined); + yield* Fiber.join(holder); + yield* Fiber.join(closed); + yield* Fiber.join(reopened); + + const batch = yield* Fiber.join(recovery); + expect(batch).toHaveLength(1); + expect(batch[0]).toMatchObject({ + type: "snapshot", + snapshot: { status: "running", pid: ptyAdapter.processes.at(-1)!.pid }, + }); + ptyAdapter.processes.at(-1)!.emitData("new session only\n"); + expect(yield* pull).toMatchObject([{ type: "output", data: "new session only\n" }]); + expect(ptyAdapter.spawnInputs).toHaveLength(4); + }).pipe(Effect.scoped), + ); - const events = yield* Ref.get(attachEvents); - expect(events.filter((event) => event.type === "snapshot")).toHaveLength(1); - }), + it.effect("does not replay in-flight output already included in an observer snapshot", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const input = openInput(); + yield* manager.open(input); + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + const delivered = yield* Deferred.make(); + // The snapshot sees history before this earlier fanout listener releases it. + const unsubscribe = yield* manager.subscribe((event) => + event.type === "output" && event.data === "in flight\n" + ? Deferred.succeed(reached, undefined).pipe(Effect.andThen(Deferred.await(release))) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + ptyAdapter.processes[0]!.emitData("in flight\n"); + yield* Deferred.await(reached); + const observed: TerminalAttachStreamEvent[] = []; + const stopObserving = yield* manager.observeStream(input, (event) => + Effect.gen(function* () { + observed.push(event); + if (event.type === "output" && event.data === "after\n") { + yield* Deferred.succeed(delivered, undefined); + } + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(stopObserving)); + expect(observed).toMatchObject([{ type: "snapshot", snapshot: { history: "in flight\n" } }]); + yield* Deferred.succeed(release, undefined); + ptyAdapter.processes[0]!.emitData("after\n"); + yield* Deferred.await(delivered); + expect(observed.filter((event) => event.type === "output")).toMatchObject([ + { data: "after\n" }, + ]); + }).pipe(Effect.scoped), ); it.effect("observes terminal history and live output without changing the process", () => @@ -3105,113 +1647,4 @@ it.layer( expect(missingEvents).toEqual([]); }), ); - - it.effect("buffers attach output delivered during the initial snapshot callback", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - yield* manager.open(openInput()); - - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream(openInput(), (event) => - Effect.gen(function* () { - yield* Ref.update(attachEvents, (events) => [...events, event]); - if (event.type === "snapshot") { - yield* Effect.sync(() => process.emitData("during snapshot\n")); - yield* Effect.yieldNow; - } - }), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - yield* waitFor( - Effect.map(Ref.get(attachEvents), (events) => - events.some((event) => event.type === "output" && event.data === "during snapshot\n"), - ), - "1200 millis", - ); - - expect(yield* Ref.get(attachEvents)).toMatchObject([ - { type: "snapshot" }, - { type: "output", data: "during snapshot\n" }, - ]); - }), - ); - - it.effect("preserves queued PTY output ordering through exit callbacks", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("first\n"); - process.emitData("second\n"); - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => { - const relevant = events.filter( - (event) => event.type === "output" || event.type === "exited", - ); - return relevant.length >= 3; - }), - "1200 millis", - ); - - const relevant = (yield* getEvents).filter( - (event) => event.type === "output" || event.type === "exited", - ); - expect(relevant).toEqual([ - expect.objectContaining({ type: "output", data: "first\n", sequence: 2 }), - expect.objectContaining({ type: "output", data: "second\n", sequence: 3 }), - expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0, sequence: 4 }), - ]); - - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream( - { - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - }, - (event) => Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); - expect(snapshot).toBeDefined(); - if (!snapshot || snapshot.type !== "snapshot") return; - expect(snapshot.snapshot.sequence).toBe(4); - }), - ); - - it.effect("scoped runtime shutdown stops active terminals cleanly", () => - Effect.gen(function* () { - const scope = yield* Scope.make("sequential"); - const { manager, ptyAdapter } = yield* createManager(5, { - processKillGraceMs: 10, - }).pipe(Effect.provideService(Scope.Scope, scope)); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const closeScope = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust("10 millis"); - yield* Fiber.join(closeScope); - - assert.equal(process.killSignals[0], "SIGTERM"); - expect(process.killSignals).toContain("SIGKILL"); - }).pipe(Effect.provide(TestClock.layer())), - ); }); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 08651591f1a8..a75f6efccf05 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1,6 +1,10 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto digest is effectful; a sync hash keeps historyPath a plain function. +import * as NodeCrypto from "node:crypto"; + import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; import { DEFAULT_TERMINAL_ID, + DEFAULT_MAX_TERMINAL_BUFFER_BYTES, TerminalCwdError, TerminalCwdNotDirectoryError, TerminalCwdNotFoundError, @@ -94,6 +98,8 @@ const MAX_HISTORY_CHUNK_LENGTH = 16 * 1024; const DEFAULT_PERSIST_DEBOUNCE_MS = 250; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; const MAX_SUBPROCESS_POLL_INTERVAL_MS = 60_000; +// Discover children started without terminal input or output. +const QUIET_SUBPROCESS_SNAPSHOT_EVERY_TICKS = 30; const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; const DEFAULT_OPEN_COLS = 120; @@ -162,11 +168,19 @@ export class TerminalManager extends Context.Service< readonly attachStream: ( input: TerminalAttachInput, listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + registerCleanup?: (stop: () => void) => void, ) => Effect.Effect<() => void, TerminalError>; readonly observeStream: ( input: TerminalObserveInput, listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + registerCleanup?: (stop: () => void) => void, + ) => Effect.Effect<() => void, TerminalError>; + + readonly resyncStream: ( + input: TerminalObserveInput, + listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + registerCleanup?: (stop: () => void) => void, ) => Effect.Effect<() => void, TerminalError>; readonly write: (input: TerminalWriteInput) => Effect.Effect; @@ -194,6 +208,7 @@ export class TerminalManager extends Context.Service< readonly subscribe: ( listener: (event: TerminalEvent) => Effect.Effect, + registerCleanup?: (stop: () => void) => void, ) => Effect.Effect<() => void>; readonly subscribeMetadata: ( @@ -425,6 +440,16 @@ function advanceEventSequence(session: TerminalSessionState): { return { updatedAt, sequence: session.eventSequence }; } +function terminalActivityMark(session: TerminalSessionState): number { + return session.eventSequence + session.inputCount; +} + +interface QuietTerminalSession { + readonly process: PtyAdapter.PtyProcess | null; + readonly mark: number; + readonly idleSnapshots: number; +} + function cleanupProcessHandles(session: TerminalSessionState): void { session.unsubscribeData?.(); session.unsubscribeData = null; @@ -629,10 +654,7 @@ interface TerminalProcessTableSnapshot { readonly commandById: ReadonlyMap; } -export function subprocessSnapshotPollDelayMs( - pollIntervalMs: number, - failureCount: number, -): number { +function subprocessSnapshotPollDelayMs(pollIntervalMs: number, failureCount: number): number { return Math.min(pollIntervalMs * 2 ** failureCount, MAX_SUBPROCESS_POLL_INTERVAL_MS); } @@ -986,6 +1008,46 @@ export class BoundedTerminalHistory { } } +// Pop beyond Ghostty's eight-entry Kitty stacks on both screens before clearing +// flags. A 1049 round trip preserves the main cursor, text, and scrollback. +const INHERITED_INPUT_RESET = + "\u001b[?1049h\u001b[<100u\u001b[=0;1u\u001b[?1049l" + + "\u001b[?1;9;66;67;1000;1002;1003;1004;1005;1006;1015;2004l" + + "\u001b[?25;1035;1036h\u001b[<100u\u001b[=0;1u\u001b[>4m"; + +function neutralizeInheritedHistory(history: string, maxLines: number, maxBytes: number): string { + // Scan exactly the cold replay window, reserving the entire longest reset so + // neither the server nor the client can trim away the entry after we choose its exit. + const resetBytes = history.includes("\u001b") + ? Buffer.byteLength(INHERITED_INPUT_RESET + "\u001b7\u001b[?1049l") + : 0; + history = new BoundedTerminalHistory( + // The suffix occupies the empty line after a trailing newline, too. + resetBytes > 0 && history.endsWith("\n") ? maxLines - 1 : maxLines, + history, + Math.max(0, maxBytes - resetBytes), + ).value(); + if (!history.includes("\u001b")) return history; + // An unconditional 1049 reset restores a stale saved cursor on the main + // screen. The exit must match the inherited screen's cursor-save behavior. + let alternateScreen: number | undefined; + // eslint-disable-next-line no-control-regex -- Match replayed alternate-screen controls. + for (const match of history.matchAll(/\u001b(?:\[\?([0-9;]+)([hl])|(c))/gu)) { + if (match[3]) alternateScreen = undefined; + for (const parameter of (match[1] ?? "").split(";")) { + const mode = Number(parameter); + if (mode === 47 || mode === 1047 || mode === 1049) { + alternateScreen = match[2] === "h" ? (alternateScreen ?? mode) : undefined; + } + } + } + // A client may trim away the entry, leaving us on main: save its current + // cursor before exiting. On alternate this saves only the alternate cursor. + const leaveAlternateScreen = + alternateScreen === undefined ? "" : `\u001b7\u001b[?${alternateScreen}l`; + return history + leaveAlternateScreen + INHERITED_INPUT_RESET; +} + function isCsiFinalByte(codePoint: number): boolean { return codePoint >= 0x40 && codePoint <= 0x7e; } @@ -1203,12 +1265,23 @@ function legacySafeThreadId(threadId: string): string { return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); } -function toSafeThreadId(threadId: string): string { +const MAX_FILE_NAME_BYTES = 255; + +function encodedHistoryThreadPart(threadId: string): string { return `terminal_${Base64Url.encode(threadId)}`; } -function toSafeTerminalId(terminalId: string): string { - return Base64Url.encode(terminalId); +function hashId(id: string): string { + return NodeCrypto.createHash("sha256").update(id).digest("base64url"); +} + +// "." is outside the base64url alphabet, so a hashed name can never collide with an encoded one. +function hashedHistoryThreadPart(threadId: string): string { + return `terminal_sha256.${hashId(threadId)}`; +} + +function joinHistoryFileName(threadPart: string, terminalPart: string | null): string { + return terminalPart === null ? `${threadPart}.log` : `${threadPart}_${terminalPart}.log`; } function toSessionKey(threadId: string, terminalId: string): string { @@ -1541,23 +1614,36 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const workerScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void)); - const publishEvent = (event: TerminalEvent) => - Effect.gen(function* () { + const publishEvent = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (event: TerminalEvent) { for (const listener of terminalEventListeners) { yield* listener(event).pipe(Effect.ignoreCause({ log: true })); } }); + // Nested subagent thread ids can encode past the filename limit. Only names that would not fit + // switch to hashed ids, so every history file that already exists keeps its path. const historyPath = (threadId: string, terminalId: string) => { - const threadPart = toSafeThreadId(threadId); - if (terminalId === DEFAULT_TERMINAL_ID) { - return path.join(logsDir, `${threadPart}.log`); + const isDefault = terminalId === DEFAULT_TERMINAL_ID; + const encodedName = joinHistoryFileName( + encodedHistoryThreadPart(threadId), + isDefault ? null : Base64Url.encode(terminalId), + ); + if (encodedName.length <= MAX_FILE_NAME_BYTES) { + return path.join(logsDir, encodedName); } - return path.join(logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); + return path.join( + logsDir, + joinHistoryFileName(hashedHistoryThreadPart(threadId), isDefault ? null : hashId(terminalId)), + ); }; - const legacyHistoryPath = (threadId: string) => - path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); + const legacyHistoryName = (threadId: string) => `${legacySafeThreadId(threadId)}.log`; + const legacyHistoryPath = (threadId: string) => path.join(logsDir, legacyHistoryName(threadId)); + // Ids this long could never have been written under the legacy name. + const hasLegacyHistoryPath = (threadId: string) => + Buffer.byteLength(legacyHistoryName(threadId)) <= MAX_FILE_NAME_BYTES; const readManagerState = SynchronizedRef.get(managerStateRef); @@ -1820,7 +1906,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return history; } - if (terminalId !== DEFAULT_TERMINAL_ID) { + if (terminalId !== DEFAULT_TERMINAL_ID || !hasLegacyHistoryPath(threadId)) { return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit); } @@ -1878,7 +1964,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ), ); - if (terminalId === DEFAULT_TERMINAL_ID) { + if (terminalId === DEFAULT_TERMINAL_ID && hasLegacyHistoryPath(threadId)) { yield* fileSystem.remove(legacyHistoryPath(threadId), { force: true }).pipe( Effect.catch((error) => Effect.logWarning("failed to delete terminal history", { @@ -1894,16 +1980,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const deleteAllHistoryForThread = Effect.fn("terminal.deleteAllHistoryForThread")(function* ( threadId: string, ) { - const threadPrefix = `${toSafeThreadId(threadId)}_`; + const threadParts = [encodedHistoryThreadPart(threadId), hashedHistoryThreadPart(threadId)]; const entries = yield* fileSystem .readDirectory(logsDir, { recursive: false }) .pipe(Effect.orElseSucceed(() => [] as Array)); yield* Effect.forEach( entries.filter( (name) => - name === `${toSafeThreadId(threadId)}.log` || name === `${legacySafeThreadId(threadId)}.log` || - name.startsWith(threadPrefix), + threadParts.some((part) => name === `${part}.log` || name.startsWith(`${part}_`)), ), (name) => fileSystem.remove(path.join(logsDir, name), { force: true }).pipe( @@ -2409,16 +2494,46 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } }); + // Two unchanged snapshots cover children forked just after the first snapshot. + // Process identity prevents a restarted shell from inheriting quiet state. + let quietSessions = new WeakMap(); + let skippedQuietTicks = 0; + const isQuiet = (session: TerminalSessionState & { pid: number }) => { + const quiet = quietSessions.get(session); + return ( + quiet !== undefined && + quiet.idleSnapshots === 2 && + quiet.process === session.process && + quiet.mark === terminalActivityMark(session) && + !session.hasRunningSubprocess + ); + }; + const pollSubprocessActivity = Effect.fn("terminal.pollSubprocessActivity")(function* () { const state = yield* readManagerState; - const runningSessions = [...state.sessions.values()].filter( - (session): session is TerminalSessionState & { pid: number } => - session.status === "running" && Number.isInteger(session.pid), - ); + const runningSessions = [...state.sessions.values()] + .filter( + (session): session is TerminalSessionState & { pid: number } => + session.status === "running" && Number.isInteger(session.pid), + ) + .map((session) => ({ + session, + terminalPid: session.pid, + process: session.process, + mark: terminalActivityMark(session), + })); if (runningSessions.length === 0) { return true; } + if ( + runningSessions.every(({ session }) => isQuiet(session)) && + skippedQuietTicks < QUIET_SUBPROCESS_SNAPSHOT_EVERY_TICKS - 1 + ) { + skippedQuietTicks += 1; + return true; + } + skippedQuietTicks = 0; const inspectorOption = yield* acquireSubprocessInspector.pipe( Effect.asSome, @@ -2437,15 +2552,18 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); if (Option.isNone(inspectorOption)) { + quietSessions = new WeakMap(); return false; } const { inspector: subprocessInspector, snapshotSucceeded } = inspectorOption.value; - const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* ( - session: TerminalSessionState & { pid: number }, + candidate: (typeof runningSessions)[number], ) { - const terminalPid = session.pid; + const { session, terminalPid, process } = candidate; + let mark = candidate.mark; + const previous = quietSessions.get(session); + quietSessions.delete(session); const inspectResult = yield* subprocessInspector(terminalPid).pipe( Effect.asSome, Effect.catch((reason) => @@ -2458,7 +2576,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ), ); - if (Option.isNone(inspectResult)) { + if ( + Option.isNone(inspectResult) || + session.status !== "running" || + session.pid !== terminalPid || + session.process !== process + ) { return; } @@ -2475,8 +2598,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); if ( Option.isNone(liveSession) || + liveSession.value !== session || liveSession.value.status !== "running" || liveSession.value.pid !== terminalPid || + liveSession.value.process !== process || (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess && liveSession.value.childCommandLabel === nextChildLabel) ) { @@ -2486,6 +2611,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess; liveSession.value.childCommandLabel = nextChildLabel; const eventStamp = advanceEventSequence(liveSession.value); + // Only this poll's own event may advance the mark without waking it. + mark += 1; return [ Option.some({ @@ -2503,12 +2630,28 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (Option.isSome(event)) { yield* publishEvent(event.value); } + + if ( + snapshotSucceeded && + !next.hasRunningSubprocess && + session.status === "running" && + session.pid === terminalPid && + session.process === process && + terminalActivityMark(session) === mark + ) { + quietSessions.set(session, { + process, + mark, + idleSnapshots: previous?.process === process && previous.mark === mark ? 2 : 1, + }); + } }); yield* Effect.forEach(runningSessions, checkSubprocessActivity, { concurrency: "unbounded", discard: true, }); + return snapshotSucceeded; }); @@ -2581,7 +2724,16 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const existing = yield* getSession(input.threadId, terminalId); if (Option.isNone(existing)) { yield* flushPersist(input.threadId, terminalId); - const history = yield* readHistory(input.threadId, terminalId); + const inherited = yield* readHistory(input.threadId, terminalId); + const history = new BoundedTerminalHistory( + historyLineLimit, + neutralizeInheritedHistory( + inherited.value(), + historyLineLimit, + Math.min(historyByteLimit, DEFAULT_MAX_TERMINAL_BUFFER_BYTES), + ), + historyByteLimit, + ); const cols = input.cols ?? DEFAULT_OPEN_COLS; const rows = input.rows ?? DEFAULT_OPEN_ROWS; const session: TerminalSessionState = { @@ -2710,60 +2862,56 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), ); - const openOrAttachForStream = (input: TerminalAttachInput) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - const terminalId = input.terminalId; - const existing = yield* getSession(input.threadId, terminalId); + const openOrAttachForStream = Effect.fnUntraced(function* (input: TerminalAttachInput) { + const terminalId = input.terminalId; + const existing = yield* getSession(input.threadId, terminalId); - if (Option.isNone(existing)) { - if (input.resume && closedSessionKeys.has(toSessionKey(input.threadId, terminalId))) { - return null; - } - if (!input.cwd) { - return yield* new TerminalSessionLookupError({ - threadId: input.threadId, - terminalId, - }); - } + if (Option.isNone(existing)) { + if (input.resume && closedSessionKeys.has(toSessionKey(input.threadId, terminalId))) { + return null; + } + if (!input.cwd) { + return yield* new TerminalSessionLookupError({ + threadId: input.threadId, + terminalId, + }); + } - const resolvedInput = yield* resolveLaunchInputEnvironment({ - ...input, - terminalId, - cwd: input.cwd, - }); - return yield* openLocked(resolvedInput); - } + const resolvedInput = yield* resolveLaunchInputEnvironment({ + ...input, + terminalId, + cwd: input.cwd, + }); + return yield* Effect.uninterruptible(openLocked(resolvedInput)); + } - const session = existing.value; - const targetCols = input.cols ?? session.cols; - const targetRows = input.rows ?? session.rows; + const session = existing.value; + const targetCols = input.cols ?? session.cols; + const targetRows = input.rows ?? session.rows; - if (!input.resume && !session.process && input.cwd && input.restartIfNotRunning === true) { - const resolvedInput = yield* resolveLaunchInputEnvironment({ - ...input, - terminalId, - cwd: input.cwd, - }); - return yield* openLocked(resolvedInput); - } + if (!input.resume && !session.process && input.cwd && input.restartIfNotRunning === true) { + const resolvedInput = yield* resolveLaunchInputEnvironment({ + ...input, + terminalId, + cwd: input.cwd, + }); + return yield* Effect.uninterruptible(openLocked(resolvedInput)); + } - if ( - session.process && - session.status === "running" && - (session.cols !== targetCols || session.rows !== targetRows) - ) { - const process = session.process; - yield* resizePtyProcess(session, process, targetCols, targetRows); - session.cols = targetCols; - session.rows = targetRows; - session.updatedAt = yield* nowIso; - } + if ( + session.process && + session.status === "running" && + (session.cols !== targetCols || session.rows !== targetRows) + ) { + const process = session.process; + yield* resizePtyProcess(session, process, targetCols, targetRows); + session.cols = targetCols; + session.rows = targetRows; + session.updatedAt = yield* nowIso; + } - return snapshot(session); - }), - ); + return snapshot(session); + }); const readAllTerminalMetadata = () => readManagerState.pipe( @@ -2787,38 +2935,70 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)), ); - const subscribe: TerminalManager["Service"]["subscribe"] = (listener) => + const subscribe: TerminalManager["Service"]["subscribe"] = (listener, registerCleanup) => Effect.sync(() => { terminalEventListeners.add(listener); - return () => { + const stop = () => { terminalEventListeners.delete(listener); }; + // Transfer ownership before an interrupt can discard the returned handle. + registerCleanup?.(stop); + return stop; }); + // Subscribe after taking the lock so contention cannot accumulate an unbounded replay. + // Snapshot listeners run under that lock and must not block. const streamSession = ( input: TerminalObserveInput, initial: Effect.Effect, listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + registerCleanup?: (stop: () => void) => void, ) => { let unsubscribe: (() => void) | null = null; + const stop = () => { + unsubscribe?.(); + unsubscribe = null; + }; return Effect.gen(function* () { const bufferedEvents: TerminalEvent[] = []; let deliverLive = false; + let snapshotFloor: number | null = null; - unsubscribe = yield* subscribe((event) => { - if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { - return Effect.void; - } + yield* subscribe( + (event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; + } - if (!deliverLive) { - bufferedEvents.push(event); - return Effect.void; - } + if (!deliverLive) { + bufferedEvents.push(event); + return Effect.void; + } - const attachEvent = terminalEventToAttachEvent(event); - return attachEvent ? listener(attachEvent) : Effect.void; - }); + // Fanout can finish after a snapshot that already includes the event. + // Keep only its sequence, not its history, until the session changes. + if (snapshotFloor !== null) { + if ( + event.type === "started" || + event.type === "restarted" || + event.type === "error" || + event.type === "closed" + ) { + snapshotFloor = null; + } else if (typeof event.sequence === "number" && event.sequence <= snapshotFloor) { + return Effect.void; + } + } + + const attachEvent = terminalEventToAttachEvent(event); + return attachEvent ? listener(attachEvent) : Effect.void; + }, + (cleanup) => { + unsubscribe = cleanup; + registerCleanup?.(stop); + }, + ); const initialSnapshot = yield* initial; @@ -2838,36 +3018,48 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func yield* listener(attachEvent); } } + // The live listener keeps this closure, so release replayed output. + bufferedEvents.length = 0; + snapshotFloor = initialSnapshot?.sequence ?? null; deliverLive = true; - return () => { - unsubscribe?.(); - unsubscribe = null; - }; + return stop; }).pipe( - Effect.catchCause((cause) => - Effect.flatMap( - Effect.sync(() => { - unsubscribe?.(); - unsubscribe = null; - }), - () => Effect.failCause(cause), - ), - ), + (effect) => withThreadLock(input.threadId, effect), + Effect.onError(() => Effect.sync(stop)), ); }; - const attachStream: TerminalManager["Service"]["attachStream"] = (input, listener) => - streamSession(input, openOrAttachForStream(input), listener); + const attachStream: TerminalManager["Service"]["attachStream"] = ( + input, + listener, + registerCleanup, + ) => streamSession(input, openOrAttachForStream(input), listener, registerCleanup); + + const observeStream: TerminalManager["Service"]["observeStream"] = ( + input, + listener, + registerCleanup, + ) => + streamSession( + input, + requireSession(input.threadId, input.terminalId).pipe(Effect.map(snapshot)), + listener, + registerCleanup, + ); - const observeStream: TerminalManager["Service"]["observeStream"] = (input, listener) => + const resyncStream: TerminalManager["Service"]["resyncStream"] = ( + input, + listener, + registerCleanup, + ) => streamSession( input, - withThreadLock( - input.threadId, - requireSession(input.threadId, input.terminalId).pipe(Effect.map(snapshot)), + getSession(input.threadId, input.terminalId).pipe( + Effect.map((session) => (Option.isSome(session) ? snapshot(session.value) : null)), ), listener, + registerCleanup, ); const metadataEventFromTerminalEvent = ( @@ -3016,84 +3208,85 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ); - const restartResolved = (input: TerminalRestartInput) => - Effect.gen(function* () { - yield* increment(terminalRestartsTotal, { scope: "thread" }); - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, terminalId); - const existingSession = yield* getSession(input.threadId, terminalId); - let session: TerminalSessionState; - if (Option.isNone(existingSession)) { - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - session = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "starting", - pid: null, - history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), - pendingHistoryControlSequence: "", - pendingProcessEvents: [], - pendingProcessEventIndex: 0, - processEventDrainRunning: false, - exitCode: null, - exitSignal: null, - updatedAt: yield* nowIso, - eventSequence: 0, - inputCount: 0, - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - childCommandLabel: null, - runtimeEnv: normalizedRuntimeEnv(input.env), - }; - const createdSession = session; - yield* modifyManagerState((state) => { - const sessions = new Map(state.sessions); - sessions.set(sessionKey, createdSession); - closedSessionKeys.delete(sessionKey); - return [undefined, { ...state, sessions }] as const; - }); - yield* evictInactiveSessionsIfNeeded(); - } else { - session = existingSession.value; - yield* stopProcess(session); - session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; - session.runtimeEnv = normalizedRuntimeEnv(input.env); - } + const restartResolved = Effect.fn("Manager.restartResolved")(function* ( + input: TerminalRestartInput, + ) { + yield* increment(terminalRestartsTotal, { scope: "thread" }); + const terminalId = input.terminalId; + yield* assertValidCwd(input.cwd); - const cols = input.cols ?? session.cols; - const rows = input.rows ?? session.rows; + const sessionKey = toSessionKey(input.threadId, terminalId); + const existingSession = yield* getSession(input.threadId, terminalId); + let session: TerminalSessionState; + if (Option.isNone(existingSession)) { + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; + session = { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + worktreePath: input.worktreePath ?? null, + status: "starting", + pid: null, + history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), + pendingHistoryControlSequence: "", + pendingProcessEvents: [], + pendingProcessEventIndex: 0, + processEventDrainRunning: false, + exitCode: null, + exitSignal: null, + updatedAt: yield* nowIso, + eventSequence: 0, + inputCount: 0, + cols, + rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + hasRunningSubprocess: false, + childCommandLabel: null, + runtimeEnv: normalizedRuntimeEnv(input.env), + }; + const createdSession = session; + yield* modifyManagerState((state) => { + const sessions = new Map(state.sessions); + sessions.set(sessionKey, createdSession); + closedSessionKeys.delete(sessionKey); + return [undefined, { ...state, sessions }] as const; + }); + yield* evictInactiveSessionsIfNeeded(); + } else { + session = existingSession.value; + yield* stopProcess(session); + session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; + session.runtimeEnv = normalizedRuntimeEnv(input.env); + } - session.history.clear(); - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - yield* persistHistory(input.threadId, terminalId, session.history); - yield* startSession( - session, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - cols, - rows, - ...(input.env ? { env: input.env } : {}), - }, - "restarted", - ); - return snapshot(session); - }); + const cols = input.cols ?? session.cols; + const rows = input.rows ?? session.rows; + + session.history.clear(); + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + yield* persistHistory(input.threadId, terminalId, session.history); + yield* startSession( + session, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + cols, + rows, + ...(input.env ? { env: input.env } : {}), + }, + "restarted", + ); + return snapshot(session); + }); const restart: TerminalManager["Service"]["restart"] = (input) => withThreadLock( @@ -3141,10 +3334,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func (input.terminalId === undefined || session.terminalId === input.terminalId), ); if (running.length === 0) return; - const activityMark = (session: TerminalSessionState) => - session.eventSequence + session.inputCount; const marks = new Map( - running.map((session) => [session.terminalId, activityMark(session)]), + running.map((session) => [session.terminalId, terminalActivityMark(session)]), ); // Inspect now instead of trusting the last poll, so a command started // since then keeps its terminal. @@ -3155,7 +3346,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func inspector(session.pid).pipe( Effect.flatMap((result) => result.hasRunningSubprocess || - activityMark(session) !== marks.get(session.terminalId) + terminalActivityMark(session) !== marks.get(session.terminalId) ? Effect.void : closeSession(input.threadId, session.terminalId, false), ), @@ -3176,6 +3367,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func open, attachStream, observeStream, + resyncStream, write, resize, clear, diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index 79f73c2e9060..66704396e9e7 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -4,14 +4,11 @@ import * as NodeNet from "node:net"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as Scheduler from "effect/Scheduler"; -import { expect, vi } from "vite-plus/test"; +import { vi } from "vite-plus/test"; import * as NodePtyAdapter from "./NodePtyAdapter.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; @@ -160,125 +157,6 @@ it.effect("reports an incompatible Windows readiness API instead of hanging", () }).pipe(Effect.provide(layerTest)), ); -it.effect.each(["win32", "linux", "darwin"] as const)( - "terminates through node-pty using %s semantics", - (platform) => - Effect.gen(function* () { - const adapter = yield* PtyAdapter.PtyAdapter; - const process = yield* adapter.spawn({ - shell: "test-shell", - cwd: ".", - cols: 80, - rows: 24, - env: {}, - }); - const nativeProcess = spawn.mock.results.at(-1)!.value; - nativeProcess.kill.mockImplementation((signal?: string) => { - if (platform === "win32" && signal) { - throw new Error("Signals not supported on windows."); - } - }); - - process.kill("SIGTERM"); - process.kill("SIGKILL"); - process.kill(); - - assert.deepEqual( - nativeProcess.kill.mock.calls, - platform === "win32" - ? [[undefined], [undefined], [undefined]] - : [["SIGTERM"], ["SIGKILL"], [undefined]], - ); - }).pipe(Effect.provide(layerTestFor(platform))), -); - -it.effect("spawns through the public adapter with the provided host references", () => - Effect.gen(function* () { - spawn.mockClear(); - const adapter = yield* PtyAdapter.PtyAdapter; - const process = yield* adapter.spawn({ - shell: "powershell.exe", - args: ["-NoLogo"], - cwd: "C:\\workspace", - cols: 120, - rows: 40, - env: {}, - }); - - assert.equal(process.pid, 42); - assert.equal(spawn.mock.calls.length, 1); - assert.deepEqual(spawn.mock.calls[0], [ - "powershell.exe", - ["-NoLogo"], - { - cwd: "C:\\workspace", - cols: 120, - rows: 40, - env: { TERM: "xterm-256color" }, - name: "xterm-256color", - }, - ]); - }).pipe(Effect.provide(layerTest)), -); - -it.effect("preserves a caller-provided TERM in the spawn env on win32", () => - Effect.gen(function* () { - spawn.mockClear(); - const adapter = yield* PtyAdapter.PtyAdapter; - yield* adapter.spawn({ - shell: "powershell.exe", - cwd: "C:\\workspace", - cols: 80, - rows: 24, - env: { TERM: "xterm-direct" }, - }); - - assert.equal(spawn.mock.calls.length, 1); - assert.deepEqual(spawn.mock.calls[0], [ - "powershell.exe", - [], - { - cwd: "C:\\workspace", - cols: 80, - rows: 24, - env: { TERM: "xterm-direct" }, - name: "xterm-256color", - }, - ]); - }).pipe(Effect.provide(layerTest)), -); - -it.effect("reports native module load failures as structured startup defects", () => - Effect.gen(function* () { - const cause = new Error("native binding could not be loaded"); - const exit = yield* NodePtyAdapter.make().pipe( - Effect.provideService(NodePtyAdapter.NodePtyModuleLoaderRef, () => Promise.reject(cause)), - Effect.exit, - ); - - assert.isTrue(Exit.isFailure(exit)); - if (Exit.isFailure(exit)) { - assert.isTrue(Cause.hasDies(exit.cause)); - const error = Cause.squash(exit.cause); - assert.instanceOf(error, NodePtyAdapter.NodePtyModuleLoadError); - assert.deepInclude(error, { - _tag: "NodePtyModuleLoadError", - platform: "win32", - architecture: "x64", - }); - assert.equal(error.message, "Failed to load node-pty for win32-x64."); - } - }).pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.succeed(HostProcess.Platform, "win32"), - Layer.succeed(HostProcess.Architecture, "x64"), - ), - ), - ), -); - it.effect.each([2048, 8])( "preserves an exit during readiness handoff with scheduler budget %s", (budget) => @@ -319,47 +197,3 @@ it.effect("replays an exit to late subscribers and respects unsubscription", () assert.equal(nativeProcess.events.listenerCount("exit"), 0); }).pipe(Effect.provide(layerTest)), ); - -it.effect.each(["spawn", "interrupt"] as const)( - "logs cleanup failures without replacing %s", - (failure) => - Effect.gen(function* () { - const { nativeProcess, subscribed } = preparePendingProcess(); - const killError = new Error("native kill failed"); - nativeProcess._agent.kill.mockImplementation(() => { - throw killError; - }); - const messages: unknown[] = []; - const logger = Logger.make(({ message }) => { - messages.push(message); - }); - const adapter = yield* PtyAdapter.PtyAdapter; - const fiber = yield* adapter - .spawn(spawnInput) - .pipe( - Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), - Effect.forkChild, - ); - yield* subscribed; - const spawnError = new Error("pipe failed"); - if (failure === "interrupt") yield* Fiber.interrupt(fiber); - else nativeProcess._socket.emit("error", spawnError); - const exit = yield* Fiber.await(fiber); - assert.isTrue(Exit.isFailure(exit)); - if (Exit.isFailure(exit)) { - if (failure === "interrupt") assert.isTrue(Cause.hasInterrupts(exit.cause)); - else { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, PtyAdapter.PtySpawnError); - assert.equal(error.cause, spawnError); - } - } - assert.equal(messages.length, 1); - expect(messages[0]).toMatchObject([ - "failed to cancel Windows terminal startup", - { terminalPid: 0, cause: { cause: killError } }, - ]); - assert.equal(nativeProcess.events.listenerCount("exit"), 0); - assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); - }).pipe(Effect.provide(layerTest)), -); diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index e9b1fe5d4b91..047c24ae8fc3 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -225,7 +225,7 @@ class NodePtyProcess implements PtyAdapter.PtyProcess { } } -export const make = Effect.fn("NodePtyAdapter.make")(function* () { +const make = Effect.fn("NodePtyAdapter.make")(function* () { const loadNodePtyModule = yield* NodePtyModuleLoaderRef; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/terminal/OutputProtocol.test.ts b/apps/server/src/terminal/OutputProtocol.test.ts index a12f9d6ecbf8..a05d635361b8 100644 --- a/apps/server/src/terminal/OutputProtocol.test.ts +++ b/apps/server/src/terminal/OutputProtocol.test.ts @@ -1,14 +1,20 @@ import { assert, describe, it } from "@effect/vitest"; -import { WS_METHODS } from "@t3tools/contracts"; +import { + type TerminalAttachStreamEvent, + TerminalAttachStreamEvent as TerminalAttachStreamEventSchema, + TerminalEvent as TerminalEventSchema, + WS_METHODS, +} from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { Rpc, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/rpc"; -import { withTerminalOutputWindow } from "./OutputProtocol.ts"; +import { terminalOutputStream, withTerminalOutputWindow } from "./OutputProtocol.ts"; describe("terminal output window", () => { it.effect.each([ @@ -77,3 +83,279 @@ describe("terminal output window", () => { }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), ); }); + +const output = (data: string): TerminalAttachStreamEvent => ({ + type: "output", + threadId: "thread-1", + terminalId: "default", + data, +}); + +const snapshot = (history: string): TerminalAttachStreamEvent => ({ + type: "snapshot", + snapshot: { + threadId: "thread-1", + terminalId: "default", + cwd: "/repo", + worktreePath: null, + status: "running", + pid: 1, + history, + exitCode: null, + exitSignal: null, + label: "bash", + updatedAt: "2026-01-01T00:00:00.000Z", + }, +}); + +// Stands in for TerminalManager's fanout: listeners run in publish order and +// may release themselves while it iterates. +const makePublisher = () => { + const listeners = new Set<(event: TerminalAttachStreamEvent) => Effect.Effect>(); + return { + listeners, + subscribe: ( + listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + registerCleanup?: (stop: () => void) => void, + ) => + Effect.sync(() => { + listeners.add(listener); + const stop = () => { + listeners.delete(listener); + }; + registerCleanup?.(stop); + return stop; + }), + publish: (event: TerminalAttachStreamEvent) => + Effect.forEach(listeners, (listener) => listener(event), { discard: true }), + }; +}; + +// Like TerminalManager.attachStream: a snapshot, then live events. +const attach = + (publisher: ReturnType, history: string) => + ( + listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + registerCleanup: (stop: () => void) => void, + ) => + publisher + .subscribe(listener, registerCleanup) + .pipe(Effect.tap(() => listener(snapshot(history)))); + +const chunkBytes = (message: RpcMessage.FromServerEncoded) => + Buffer.byteLength(JSON.stringify(message)); + +describe("terminal output stream", () => { + it.effect.each([ + WS_METHODS.terminalAttach, + WS_METHODS.terminalObserve, + WS_METHODS.subscribeTerminalEvents, + ])("releases only the ACK-stalled subscriber on %s and resumes safely", (tag) => + Effect.gen(function* () { + const legacy = tag === WS_METHODS.subscribeTerminalEvents; + const group = RpcGroup.make( + Rpc.make(tag, { + success: legacy ? TerminalEventSchema : TerminalAttachStreamEventSchema, + stream: true, + }), + ); + const publisher = makePublisher(); + const stalled = yield* Queue.unbounded(); + const healthy = yield* Queue.unbounded(); + const receive = yield* Deferred.make[0]>(); + const protocol = yield* RpcServer.Protocol.make((write) => + Effect.gen(function* () { + yield* Deferred.succeed(receive, write); + const serialization = yield* RpcSerialization.RpcSerialization; + return { + disconnects: yield* Queue.unbounded(), + send: (clientId, response) => Queue.offer(clientId === 0 ? stalled : healthy, response), + end: () => Effect.void, + clientIds: Effect.succeed(new Set([0, 1])), + initialMessage: Effect.succeedNone, + supportsAck: true, + supportsTransferables: false, + supportsSpanPropagation: false, + supportsNotifications: true, + codecFor: serialization.codecFor, + }; + }), + ); + const initial = legacy ? output("ready") : snapshot(""); + yield* RpcServer.make(group).pipe( + Effect.provide( + group.toLayerHandler(tag, () => + terminalOutputStream( + (listener, registerCleanup) => + publisher + .subscribe(listener, registerCleanup) + .pipe(Effect.tap(() => listener(initial))), + legacy ? undefined : attach(publisher, "fresh history"), + 256 * 1024, + ), + ), + ), + Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), + Effect.forkScoped, + ); + const write = yield* Deferred.await(receive); + const takeChunk = Effect.fnUntraced(function* (queue: typeof stalled) { + const response = yield* Queue.take(queue); + assert.equal(response._tag, "Chunk"); + if (response._tag !== "Chunk") throw new Error("Expected an RPC chunk"); + assert.isAtMost(chunkBytes(response), 70 * 1024); + return response.values; + }); + yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] }); + assert.deepEqual(yield* takeChunk(stalled), [initial]); + yield* write(1, { _tag: "Request", id: "2", tag, payload: null, headers: [] }); + assert.deepEqual(yield* takeChunk(healthy), [initial]); + yield* write(1, { _tag: "Ack", requestId: "2" }); + + // Eight unacknowledged chunks fill only client 0's output window. + for (let index = 0; index < 7; index++) { + const event = output("before-stall-" + index); + yield* publisher.publish(event); + assert.deepEqual(yield* takeChunk(stalled), [event]); + assert.deepEqual(yield* takeChunk(healthy), [event]); + yield* write(1, { _tag: "Ack", requestId: "2" }); + } + assert.equal(publisher.listeners.size, 2); + for (let index = 0; index < 100; index++) { + const event = output(index + ":" + "界".repeat(1365)); + yield* publisher.publish(event); + assert.deepEqual(yield* takeChunk(healthy), [event]); + yield* write(1, { _tag: "Ack", requestId: "2" }); + if (index === 31) assert.equal(publisher.listeners.size, 2); + } + assert.equal(yield* Queue.size(stalled), 0); + assert.equal(publisher.listeners.size, 1); + + yield* write(0, { _tag: "Ack", requestId: "1" }); + if (legacy) { + assert.deepEqual(yield* Queue.take(stalled), { + _tag: "Exit", + requestId: "1", + exit: { _tag: "Success", value: null }, + }); + } else { + assert.deepEqual(yield* takeChunk(stalled), [snapshot("fresh history")]); + assert.equal(publisher.listeners.size, 2); + for (let index = 0; index < 7; index++) { + yield* write(0, { _tag: "Ack", requestId: "1" }); + } + } + const after = output("unique output after recovery"); + yield* publisher.publish(after); + assert.deepEqual(yield* takeChunk(healthy), [after]); + if (!legacy) assert.deepEqual(yield* takeChunk(stalled), [after]); + assert.equal(yield* Queue.size(stalled), 0); + }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), + ); + + it.effect("interrupts recovery while its subscription is still waiting", () => + Effect.gen(function* () { + const publisher = makePublisher(); + const recovering = yield* Deferred.make(); + const interrupted = yield* Deferred.make(); + const pull = yield* Stream.toPull( + terminalOutputStream( + attach(publisher, ""), + () => + Deferred.succeed(recovering, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), + ), + 1024, + ), + ); + yield* pull; + yield* publisher.publish(output("x".repeat(1024))); + yield* publisher.publish(output("x".repeat(1024))); + assert.equal(publisher.listeners.size, 0); + + const recovery = yield* pull.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(recovering); + yield* Fiber.interrupt(recovery); + yield* Deferred.await(interrupted); + assert.equal(publisher.listeners.size, 0); + }).pipe(Effect.scoped), + ); + + it.effect("cleans listeners when cancelled during a successful acquisition handoff", () => + Effect.gen(function* () { + const publisher = makePublisher(); + for (const recovering of [false, true, false, true]) { + const handoff = yield* Deferred.make(); + const complete = yield* Deferred.make(); + const acquire: ReturnType = (listener, registerCleanup) => + attach(publisher, "")(listener, registerCleanup).pipe( + // Hold the successful exit before its cleanup handle reaches the caller. + Effect.onExit(() => + Deferred.succeed(handoff, undefined).pipe(Effect.andThen(Deferred.await(complete))), + ), + ); + const consumer = yield* Effect.gen(function* () { + const pull = yield* Stream.toPull( + terminalOutputStream(recovering ? attach(publisher, "") : acquire, acquire, 1024), + ); + yield* pull; + yield* publisher.publish(output("x".repeat(1024))); + yield* publisher.publish(output("x".repeat(1024))); + yield* pull; + }).pipe(Effect.scoped, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(handoff); + assert.equal(publisher.listeners.size, 1); + const cancellation = yield* Fiber.interrupt(consumer).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(complete, undefined); + yield* Fiber.join(cancellation); + assert.equal(publisher.listeners.size, 0); + } + }).pipe(Effect.scoped), + ); + + it.effect("caps each delivered batch at the output window in UTF-8 bytes", () => + Effect.gen(function* () { + const publisher = makePublisher(); + const pull = yield* Stream.toPull(terminalOutputStream(attach(publisher, ""))); + assert.deepEqual(yield* pull, [snapshot("")]); + // 1,024 code units but 3 KiB of UTF-8. + const data = "界".repeat(1024); + for (let index = 0; index < 1024; index++) yield* publisher.publish(output(data)); + + let delivered = 0; + while (delivered < 1024) { + const batch = yield* pull; + assert.isAtMost( + batch.reduce( + (bytes, event) => bytes + (event.type === "output" ? Buffer.byteLength(event.data) : 0), + 0, + ), + 64 * 1024, + ); + delivered += batch.length; + } + assert.equal(delivered, 1024); + }).pipe(Effect.scoped), + ); + + it.effect("charges snapshot history but never overflows on one snapshot", () => + Effect.gen(function* () { + const publisher = makePublisher(); + const history = "界".repeat(16 * 1024); + const events = terminalOutputStream(attach(publisher, history), undefined, 32 * 1024); + const pull = yield* Stream.toPull(events); + // A snapshot larger than the budget is still delivered. + assert.deepEqual(yield* pull, [snapshot(history)]); + assert.equal(publisher.listeners.size, 1); + + // Queued snapshots count their history: a second one overflows. + yield* publisher.publish(snapshot(history)); + assert.equal(publisher.listeners.size, 1); + yield* publisher.publish(snapshot(history)); + assert.equal(publisher.listeners.size, 0); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/terminal/OutputProtocol.ts b/apps/server/src/terminal/OutputProtocol.ts index 402ed32c022a..5a0f596e8478 100644 --- a/apps/server/src/terminal/OutputProtocol.ts +++ b/apps/server/src/terminal/OutputProtocol.ts @@ -1,5 +1,9 @@ -import { WS_METHODS } from "@t3tools/contracts"; +import { type TerminalAttachStreamEvent, type TerminalEvent, WS_METHODS } from "@t3tools/contracts"; +import type * as Arr from "effect/Array"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Latch from "effect/Latch"; +import * as Stream from "effect/Stream"; import type { RpcServer } from "effect/rpc"; const MAX_PENDING_CHUNKS = 8; @@ -66,3 +70,117 @@ export function withTerminalOutputWindow( }), }; } + +const MAX_QUEUED_OUTPUT = 8 * 1024 * 1024; +// Charge an envelope so tiny events cannot bypass the byte budget. +const EVENT_OVERHEAD = 128; + +type Listener = (event: A) => Effect.Effect; +// Acquisition must register cleanup synchronously with installing the listener. +type Subscribe = ( + listener: Listener, + registerCleanup: (stop: () => void) => void, +) => Effect.Effect<() => void, E>; + +const queuedSize = (event: TerminalEvent | TerminalAttachStreamEvent) => + EVENT_OVERHEAD + + (event.type === "output" + ? Buffer.byteLength(event.data) + : "snapshot" in event + ? Buffer.byteLength(event.snapshot.history) + : 0); + +export function terminalOutputStream( + subscribe: Subscribe, + resync?: Subscribe, + maxQueued = MAX_QUEUED_OUTPUT, +): Stream.Stream { + return Stream.unwrap( + Effect.gen(function* () { + const ready = yield* Latch.make(); + let pending: Array = []; + let sizes: number[] = []; + let head = 0; + let queued = 0; + let overflowed = false; + let subscription = 0; + let unsubscribe: (() => void) | null = null; + const release = () => { + subscription += 1; + unsubscribe?.(); + unsubscribe = null; + pending = []; + sizes = []; + head = 0; + queued = 0; + }; + yield* Effect.addFinalizer(() => Effect.sync(release)); + + const listen = (from: Subscribe) => + Effect.suspend(() => { + const id = ++subscription; + return from( + (event) => + Effect.sync(() => { + if (id !== subscription) return; + const size = queuedSize(event); + pending.push(event); + sizes.push(size); + queued += size; + // A single snapshot must fit even when history fills the budget. + if (queued - sizes[head]! > maxQueued) { + release(); + overflowed = true; + } + ready.openUnsafe(); + }), + (stop) => { + // Overflow or cancellation may invalidate acquisition before registration. + if (id === subscription) unsubscribe = stop; + else stop(); + }, + ).pipe( + Effect.asVoid, + Effect.onError(() => Effect.sync(release)), + ); + }); + + const take = () => { + const event = pending[head]!; + pending[head] = undefined; + head += 1; + return event; + }; + const takeBatch = (): Arr.NonEmptyReadonlyArray => { + let bytes = sizes[head]!; + const batch: [A, ...A[]] = [take()]; + while (head < pending.length && bytes + sizes[head]! <= MAX_PENDING_BYTES) { + bytes += sizes[head]!; + batch.push(take()); + } + queued -= bytes; + if (head * 2 >= pending.length) { + pending = pending.slice(head); + sizes = sizes.slice(head); + head = 0; + } + return batch; + }; + + const pull: Effect.Effect, E | Cause.Done> = Effect.suspend( + () => { + if (overflowed) { + overflowed = false; + return resync ? listen(resync).pipe(Effect.andThen(pull)) : Cause.done(); + } + if (head < pending.length) return Effect.succeed(takeBatch()); + ready.closeUnsafe(); + return ready.await.pipe(Effect.andThen(pull)); + }, + ); + + yield* listen(subscribe); + return Stream.fromPull(Effect.succeed(pull)); + }), + ); +} diff --git a/apps/server/src/terminal/nodePtyConsoleList.test.ts b/apps/server/src/terminal/nodePtyConsoleList.test.ts index b412f3c8502a..be538491877c 100644 --- a/apps/server/src/terminal/nodePtyConsoleList.test.ts +++ b/apps/server/src/terminal/nodePtyConsoleList.test.ts @@ -61,6 +61,7 @@ describe("node-pty console process list helper", () => { expect(list).toEqual([SHELL_PID]); }); + // regression: #18519 Windows terminal kill waited on timeouts it("falls back to the shell PID when the helper fails to spawn", async () => { const list = await getConsoleProcessList((helper) => { helper.emit("error", Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" })); diff --git a/apps/server/src/testUtils/processProbe.ts b/apps/server/src/testUtils/processProbe.ts new file mode 100644 index 000000000000..9970359244be --- /dev/null +++ b/apps/server/src/testUtils/processProbe.ts @@ -0,0 +1,73 @@ +// @effect-diagnostics nodeBuiltinImport:off - a socket observes fixture processes after their parent exits. +import * as NodeNet from "node:net"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; + +export const makeProcessProbe = Effect.gen(function* () { + const started = yield* Deferred.make(); + const exited = yield* Deferred.make(); + const sockets = new Set(); + const server = NodeNet.createServer((socket) => { + sockets.add(socket); + let output = ""; + socket.on("data", (chunk) => { + output += chunk.toString(); + if (output.includes("\n")) { + const pid = Number(output.trim()); + if (Number.isSafeInteger(pid) && pid > 0) { + Deferred.doneUnsafe(started, Effect.succeed(pid)); + } + } + }); + socket.on("close", () => { + sockets.delete(socket); + Deferred.doneUnsafe(exited, Effect.void); + }); + }); + yield* Effect.acquireRelease( + Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))); + server.listen(0, "127.0.0.1", () => resume(Effect.void)); + }), + () => + Effect.sync(() => { + for (const socket of sockets) socket.destroy(); + server.close(); + }), + ); + const address = server.address(); + if (address === null || typeof address === "string") { + return yield* Effect.die("Missing process probe port"); + } + return { + port: address.port, + started: Deferred.await(started).pipe( + Effect.timeout("4 seconds"), + TestClock.withLive, + Effect.tap((pid) => + Effect.addFinalizer(() => + Effect.gen(function* () { + if (yield* Deferred.isDone(exited)) return; + // Only a PID reported by this fixture is eligible for failure cleanup. + yield* Effect.sync(() => { + try { + process.kill(pid, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + }); + }), + ), + ), + ), + exited: Deferred.await(exited).pipe(Effect.timeout("4 seconds"), TestClock.withLive), + }; +}); + +export const processProbeSource = (port: number) => ` + const { createConnection } = await import("node:net"); + const probe = createConnection(${port}, "127.0.0.1", () => { + probe.write(process.pid + "\\n"); + }); +`; diff --git a/apps/server/src/testUtils/shardWeights.json b/apps/server/src/testUtils/shardWeights.json index 54e1203030ad..d0f1fafc6968 100644 --- a/apps/server/src/testUtils/shardWeights.json +++ b/apps/server/src/testUtils/shardWeights.json @@ -1,429 +1,216 @@ { "integration/transferBudgetV2.integration.test.ts": 3.1, - "src/assets/AssetAccess.test.ts": 1.7, - "src/assets/AttachmentUpload.test.ts": 0.4, - "src/assets/NativeAppIconResolver.test.ts": 1.0, - "src/assets/icns.test.ts": 0.1, - "src/attachmentStore.test.ts": 0.3, - "src/auth/EnvironmentAuth.test.ts": 0.9, + "src/assets/AssetAccess.test.ts": 1.3, + "src/auth/EnvironmentAuth.test.ts": 0.8, "src/auth/EnvironmentAuthAdmin.test.ts": 1.6, - "src/auth/EnvironmentAuthPolicy.test.ts": 0.4, "src/auth/McpOAuth.test.ts": 1.5, - "src/auth/PairingGrantStore.test.ts": 0.6, - "src/auth/RpcAuthorization.test.ts": 0.4, - "src/auth/ServerSecretStore.test.ts": 0.4, - "src/auth/SessionStore.test.ts": 1.4, + "src/auth/SessionStore.test.ts": 0.9, "src/auth/dpop.test.ts": 0.5, "src/auth/http.test.ts": 0.5, "src/auth/replayMarkers.test.ts": 3.7, - "src/auth/utils.test.ts": 0.1, - "src/background/BackgroundPolicy.test.ts": 0.3, - "src/background/HostPowerMonitor.test.ts": 0.4, - "src/bootstrap.test.ts": 0.4, - "src/checkpointing/CheckpointDiffQuery.test.ts": 1.3, - "src/checkpointing/CheckpointStore.test.ts": 1.7, - "src/checkpointing/Diffs.test.ts": 0.1, - "src/claudeModelOptions.test.ts": 0.4, + "src/checkpointing/CheckpointDiffQuery.test.ts": 1.1, + "src/checkpointing/CheckpointStore.test.ts": 0.9, "src/cli/app.test.ts": 2.4, "src/cli/config.test.ts": 0.6, - "src/cli/connect.test.ts": 0.4, - "src/cli/invocation.test.ts": 0.2, - "src/cli/pair.test.ts": 3.2, - "src/cli/project.test.ts": 3.1, + "src/cli/pair.test.ts": 2.4, + "src/cli/project.test.ts": 2.4, "src/cli/service.test.ts": 0.5, - "src/cli/sshHelper.test.ts": 2.4, "src/cli/theme.test.ts": 2.1, "src/cli/trace.test.ts": 1.2, "src/cli/triage.test.ts": 0.5, - "src/cli/triagePrompt.test.ts": 0.1, "src/cli/uninstall.test.ts": 0.5, "src/cli/update.test.ts": 1.7, - "src/cli/updateProgress.test.ts": 0.1, - "src/cliAuthFormat.test.ts": 0.2, - "src/cloud/CliState.test.ts": 0.3, "src/cloud/CliTokenManager.test.ts": 0.7, - "src/cloud/CloudLink.lifecycle.test.ts": 1.9, + "src/cloud/CloudLink.lifecycle.test.ts": 0.9, "src/cloud/CloudLink.test.ts": 1.1, - "src/cloud/ManagedEndpointRuntime.test.ts": 0.4, - "src/cloud/bootService.test.ts": 0.6, - "src/cloud/environmentKeys.test.ts": 0.4, + "src/cloud/bootService.test.ts": 0.5, "src/cloud/http.test.ts": 1.2, - "src/cloud/linkChecks.test.ts": 0.2, "src/cloud/managedTunnelStartup.test.ts": 0.5, - "src/cloud/pinnedRuntime.test.ts": 0.4, - "src/cloud/publicConfig.test.ts": 0.2, - "src/cloud/relayResponse.test.ts": 0.3, "src/cloud/selfUpdate.test.ts": 0.6, - "src/cloud/serviceLauncherClient.test.ts": 0.2, - "src/cloud/servicePreflight.test.ts": 0.1, - "src/cloud/traceRelayRequest.test.ts": 0.2, "src/codexModelOptions.test.ts": 0.6, - "src/compileCache.test.ts": 0.3, - "src/desktopUpdate/DesktopAppUpdate.test.ts": 0.3, - "src/device/AgentDeviceTarget.test.ts": 0.3, "src/device/DeviceActions.test.ts": 0.7, "src/device/DeviceHubProxy.stream.test.ts": 0.6, - "src/device/DeviceHubProxy.test.ts": 0.4, "src/device/DeviceMultiHost.test.ts": 0.5, "src/device/DeviceService.test.ts": 0.5, - "src/device/DeviceToolchain.test.ts": 0.4, - "src/device/LocalDeviceHost.test.ts": 0.4, "src/device/SshDeviceHost.test.ts": 0.5, - "src/device/deviceToolMaintenance.test.ts": 1.7, - "src/device/localSshDeviceHost.test.ts": 0.2, - "src/device/sshDeviceScript.test.ts": 3.3, - "src/diagnostics/ProcessDiagnostics.test.ts": 0.3, - "src/diagnostics/ProcessResourceMonitor.test.ts": 0.4, - "src/diagnostics/TraceDiagnostics.test.ts": 0.4, - "src/entrypoint.test.ts": 0.1, - "src/environment/DirectEndpoints.test.ts": 0.4, - "src/environment/RemoteOpenTargets.test.ts": 0.4, + "src/device/deviceToolMaintenance.test.ts": 1.3, "src/environment/ServerEnvironment.test.ts": 0.7, - "src/environment/ServerEnvironmentLabel.test.ts": 0.3, - "src/environment/ServerEnvironmentMachine.test.ts": 0.3, - "src/environmentTheme.test.ts": 0.6, - "src/git/GitManager.test.ts": 46.5, + "src/git/GitManager.test.ts": 13.7, "src/git/GitWorkflowService.test.ts": 2.7, - "src/git/detachStackFrame.memory.test.ts": 0.8, "src/git/linkCreatedPullRequest.test.ts": 1.2, - "src/htmlRender/HtmlRender.test.ts": 0.4, - "src/htmlRender/publicProxy.test.ts": 0.2, - "src/http.test.ts": 2.0, - "src/httpResponseErrorGuard.test.ts": 0.2, - "src/imageMime.test.ts": 0.1, - "src/keybindings.test.ts": 0.4, - "src/mcp/AcpMcpStdioBridge.test.ts": 0.3, + "src/http.test.ts": 2, "src/mcp/McpDeviceToolkit.test.ts": 1.5, - "src/mcp/McpHttpServer.test.ts": 2.2, - "src/mcp/McpInvocationContext.test.ts": 0.3, + "src/mcp/McpHttpServer.test.ts": 1.3, "src/mcp/McpSessionRegistry.test.ts": 1.1, "src/mcp/McpToolAccess.race.test.ts": 1.4, "src/mcp/McpToolAccess.test.ts": 1.2, "src/mcp/OrchestratorMcpService.activity.test.ts": 1.1, "src/mcp/OrchestratorMcpService.test.ts": 1.2, - "src/mcp/OrchestratorMcpToolkit.integration.test.ts": 14.4, + "src/mcp/OrchestratorMcpToolkit.integration.test.ts": 1.2, "src/mcp/PreviewAutomationBroker.test.ts": 0.5, - "src/mcp/TaskCancelNativeSubagent.integration.test.ts": 1.4, - "src/mcp/ThreadMetadataMcpService.test.ts": 1.2, - "src/mcp/WorktreeMcpService.test.ts": 1.2, + "src/mcp/TaskCancelNativeSubagent.integration.test.ts": 1, + "src/mcp/WorktreeMcpService.test.ts": 0.9, "src/mcp/toolkits/core.test.ts": 1.7, "src/mcp/toolkits/device/handlers.test.ts": 1.4, "src/mcp/toolkits/environment/handlers.test.ts": 1.1, - "src/mcp/toolkits/orchestrator/tools.test.ts": 1.1, "src/mcp/toolkits/preview/handlers.test.ts": 1.4, "src/mcp/toolkits/preview/tools.test.ts": 3.6, "src/mcp/toolkits/previewControls/handlers.test.ts": 1.6, "src/mcp/toolkits/project/handlers.test.ts": 1.3, "src/mcp/toolkits/pullRequests/handlers.test.ts": 1.3, - "src/mcp/toolkits/worktree/registration.test.ts": 1.5, - "src/mcpApps/McpAppModelContext.test.ts": 1.0, + "src/mcpApps/McpAppModelContext.test.ts": 1, "src/mcpApps/McpAppRequests.test.ts": 1.3, "src/observability/DefectReporter.test.ts": 1.2, - "src/observability/EventLoopMonitor.test.ts": 0.3, - "src/observability/HeapSnapshot.test.ts": 0.4, - "src/observability/Metrics.test.ts": 1.0, "src/observability/OtlpEndpointCheck.test.ts": 0.5, - "src/observability/RpcInstrumentation.test.ts": 0.3, - "src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts": 1.3, - "src/orchestration-v2/Adapters/AcpAdapterV2.test.ts": 55.1, - "src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts": 2.6, - "src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts": 1.3, - "src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts": 9.5, + "src/orchestration-v2/Adapters/AcpAdapterV2.test.ts": 25.5, + "src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts": 1.3, + "src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts": 1.1, "src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts": 0.6, - "src/orchestration-v2/Adapters/CodexAdapterV2.test.ts": 7.2, - "src/orchestration-v2/Adapters/GrokAdapterV2.test.ts": 0.5, - "src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts": 4.8, - "src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts": 0.2, - "src/orchestration-v2/AttachmentClaims.test.ts": 0.4, - "src/orchestration-v2/BackgroundWorkStop.integration.test.ts": 5.7, + "src/orchestration-v2/Adapters/CodexAdapterV2.test.ts": 4.5, + "src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts": 2.8, + "src/orchestration-v2/BackgroundWorkStop.integration.test.ts": 4.3, "src/orchestration-v2/CheckpointCaptureService.test.ts": 0.5, "src/orchestration-v2/CheckpointRestoreSafety.test.ts": 0.6, "src/orchestration-v2/CheckpointRollbackService.test.ts": 0.6, "src/orchestration-v2/CheckpointScopeOwnership.test.ts": 2.6, - "src/orchestration-v2/CheckpointService.test.ts": 0.4, - "src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts": 2.1, + "src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts": 1.6, "src/orchestration-v2/CommandPolicy.test.ts": 0.6, - "src/orchestration-v2/ContextHandoffBudget.test.ts": 0.4, - "src/orchestration-v2/ContextHandoffService.test.ts": 0.3, - "src/orchestration-v2/CursorOrchestratorV2.live.test.ts": 2.0, "src/orchestration-v2/DelegatedCompletionDelivery.test.ts": 2.1, "src/orchestration-v2/DispatchModeLimit.test.ts": 1.1, "src/orchestration-v2/EffectWorker.test.ts": 1.2, - "src/orchestration-v2/FoundationPersistence.test.ts": 4.7, - "src/orchestration-v2/GrokOrchestratorV2.live.test.ts": 1.2, - "src/orchestration-v2/LiveStreamBudget.test.ts": 0.2, - "src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts": 6.3, - "src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts": 1.7, + "src/orchestration-v2/FoundationPersistence.test.ts": 3.2, + "src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts": 4, "src/orchestration-v2/Orchestrator.control-reads.test.ts": 1.4, "src/orchestration-v2/Orchestrator.migration.test.ts": 1.5, - "src/orchestration-v2/ProjectCommands.test.ts": 0.3, "src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts": 1.4, - "src/orchestration-v2/ProjectStore.test.ts": 0.4, "src/orchestration-v2/ProjectionControlReads.test.ts": 5.3, "src/orchestration-v2/ProjectionRecovery.test.ts": 1.7, "src/orchestration-v2/ProjectionSettlement.test.ts": 1.3, - "src/orchestration-v2/ProjectionStore.test.ts": 2.6, - "src/orchestration-v2/ProviderAdapterRegistry.test.ts": 0.4, + "src/orchestration-v2/ProjectionStore.test.ts": 1.5, "src/orchestration-v2/ProviderContinuationService.test.ts": 1.2, - "src/orchestration-v2/ProviderEventIngestor.test.ts": 4.6, - "src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts": 2.5, + "src/orchestration-v2/ProviderEventIngestor.test.ts": 2.1, "src/orchestration-v2/ProviderRuntimeRecoveryService.regression.test.ts": 1.4, "src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts": 1.2, - "src/orchestration-v2/ProviderSelectionTransition.test.ts": 0.4, - "src/orchestration-v2/ProviderSessionManager.test.ts": 2.6, + "src/orchestration-v2/ProviderSessionManager.test.ts": 1.8, "src/orchestration-v2/ProviderSessionTransitionPolicy.test.ts": 0.5, "src/orchestration-v2/ProviderSwitchService.test.ts": 0.6, "src/orchestration-v2/ProviderTurnControlService.test.ts": 1.3, - "src/orchestration-v2/ProviderTurnStartService.memory.test.ts": 8.0, "src/orchestration-v2/ProviderTurnStartService.test.ts": 1.4, "src/orchestration-v2/ProviderTurnTokenUsage.test.ts": 0.6, "src/orchestration-v2/PullRequestSyncReactor.test.ts": 2.3, - "src/orchestration-v2/QueuedRunOrder.test.ts": 0.1, - "src/orchestration-v2/RestartBackgroundNote.test.ts": 0.3, "src/orchestration-v2/RestartContinuation.test.ts": 1.5, "src/orchestration-v2/RunCompletionReads.test.ts": 0.5, "src/orchestration-v2/RunExecutionService.test.ts": 1.4, "src/orchestration-v2/RunFinalizationService.test.ts": 1.1, - "src/orchestration-v2/RuntimePolicy.test.ts": 0.3, "src/orchestration-v2/RuntimeRequestService.test.ts": 0.6, - "src/orchestration-v2/SelectionRestart.integration.test.ts": 3.7, + "src/orchestration-v2/SelectionRestart.integration.test.ts": 2.4, "src/orchestration-v2/ShellStream.test.ts": 0.5, - "src/orchestration-v2/SteeringCompletion.integration.test.ts": 5.5, - "src/orchestration-v2/SubagentProjection.test.ts": 0.4, + "src/orchestration-v2/SteeringCompletion.integration.test.ts": 4.5, "src/orchestration-v2/ThreadDeletion.test.ts": 0.5, - "src/orchestration-v2/ThreadFind.test.ts": 1.6, + "src/orchestration-v2/ThreadFind.test.ts": 1, "src/orchestration-v2/ThreadFork.execution.test.ts": 1.4, - "src/orchestration-v2/ThreadForkService.test.ts": 0.4, - "src/orchestration-v2/ThreadLaunchService.test.ts": 4.5, - "src/orchestration-v2/ThreadLifecycleService.test.ts": 1.9, - "src/orchestration-v2/ThreadLiveEventCoalescer.test.ts": 0.3, - "src/orchestration-v2/ThreadManagementService.test.ts": 2.0, + "src/orchestration-v2/ThreadLaunchService.test.ts": 2.1, + "src/orchestration-v2/ThreadManagementService.test.ts": 0.7, "src/orchestration-v2/ThreadMessageIntake.test.ts": 1.1, "src/orchestration-v2/ThreadPullRequestService.test.ts": 1.2, "src/orchestration-v2/ThreadSearch.test.ts": 0.6, - "src/orchestration-v2/ThreadSettlementService.test.ts": 2.0, - "src/orchestration-v2/ThreadStop.test.ts": 2.5, - "src/orchestration-v2/ThreadStream.test.ts": 0.3, - "src/orchestration-v2/ThreadTitleRegenerationService.test.ts": 1.8, - "src/orchestration-v2/ThreadTransportPerformance.test.ts": 0.4, + "src/orchestration-v2/ThreadSettlementService.test.ts": 2, + "src/orchestration-v2/ThreadStop.test.ts": 2.1, + "src/orchestration-v2/ThreadTitleRegenerationService.test.ts": 1.2, "src/orchestration-v2/TurnItemPositionStore.test.ts": 0.5, "src/orchestration-v2/TurnStartReads.test.ts": 0.6, - "src/orchestration-v2/UserFacingErrors.test.ts": 0.4, - "src/orchestration-v2/V1ImportBoundary.test.ts": 0.3, - "src/orchestration-v2/WireProjection.test.ts": 0.4, - "src/orchestration-v2/assistantStreaming.test.ts": 0.3, "src/orchestration-v2/boundedSnapshotTransport.test.ts": 2.2, - "src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts": 1.8, + "src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts": 1.4, "src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts": 0.5, - "src/orchestration-v2/pullRequestWatch.test.ts": 0.4, - "src/orchestration-v2/runtimeLayer.test.ts": 3.1, - "src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts": 2.7, - "src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts": 1.7, - "src/orchestration-v2/testkit/OrchestratorReplayFixtures.contract.test.ts": 1.3, - "src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts": 60.3, - "src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts": 2.7, - "src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts": 2.4, - "src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts": 27.3, - "src/orchestration-v2/testkit/ThreadFork.integration.test.ts": 4.3, - "src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts": 6.6, - "src/orchestration-v2/threadHistoryPaging.test.ts": 0.3, - "src/orchestration-v2/toolOutputImageBytes.test.ts": 0.3, - "src/orchestration-v2/workflowAgentAnswers.test.ts": 0.3, + "src/orchestration-v2/runtimeLayer.test.ts": 2.8, + "src/orchestration-v2/testkit/AcpRegistryReplayFixtures.integration.test.ts": 4.8, + "src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts": 13.7, + "src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts": 6.4, + "src/orchestration-v2/testkit/CursorReplayFixtures.integration.test.ts": 4.8, + "src/orchestration-v2/testkit/GrokReplayFixtures.integration.test.ts": 5.1, + "src/orchestration-v2/testkit/MuseReplayFixtures.integration.test.ts": 2.4, + "src/orchestration-v2/testkit/OpenCodeReplayFixtures.integration.test.ts": 5.8, + "src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts": 2.2, + "src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts": 2.1, + "src/orchestration-v2/testkit/PiReplayFixtures.integration.test.ts": 4.3, + "src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts": 16.1, + "src/orchestration-v2/testkit/ThreadFork.integration.test.ts": 2.9, + "src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts": 5.1, "src/orchestration-v2/workflowScriptQuery.test.ts": 0.9, - "src/os-jank.test.ts": 0.2, - "src/persistence/Errors.test.ts": 0.2, "src/persistence/Migrations.test.ts": 0.5, - "src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts": 0.4, - "src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts": 0.4, "src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts": 1.1, - "src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts": 0.4, - "src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts": 0.3, - "src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts": 0.3, - "src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts": 0.4, - "src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts": 0.4, - "src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts": 0.8, - "src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts": 0.4, - "src/persistence/Migrations/041_AuthSessionClientConnection.test.ts": 0.4, - "src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts": 0.3, - "src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.test.ts": 0.4, - "src/persistence/Migrations/047_ProjectionProjectIcon.test.ts": 0.4, "src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts": 0.6, - "src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts": 0.4, "src/persistence/Migrations/051_ProjectionThreadMessageContext.test.ts": 0.5, - "src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.test.ts": 0.3, - "src/persistence/Migrations/055_OrchestrationV2.application-event-source.test.ts": 0.4, - "src/persistence/Migrations/055_OrchestrationV2.effect-cancellation.test.ts": 0.4, - "src/persistence/Migrations/055_OrchestrationV2.test.ts": 1.4, - "src/persistence/Migrations/056_RemoveRedundantProjectionIndexes.test.ts": 0.5, "src/persistence/Migrations/057_ScheduledTaskWebhooks.test.ts": 0.5, - "src/persistence/OrchestrationEventStore.sequence.test.ts": 0.6, - "src/persistence/OrchestrationEventStore.test.ts": 2.5, - "src/persistence/RepositoryErrorCorrelation.test.ts": 0.4, - "src/persistence/Sqlite.test.ts": 1.0, + "src/persistence/OrchestrationEventStore.test.ts": 0.5, "src/persistence/initializeV2Database.test.ts": 0.6, "src/persistence/reconcileV2PreviewMigration.test.ts": 1.1, - "src/preview/DesktopBrowserChannel.test.ts": 0.5, - "src/preview/Manager.test.ts": 0.3, - "src/preview/PortScanner.test.ts": 0.4, - "src/preview/PreviewBrowser.test.ts": 0.4, - "src/preview/PreviewBrowserHost.test.ts": 0.2, - "src/preview/ServerBrowser.test.ts": 0.9, "src/preview/ServerBrowserContexts.test.ts": 0.6, - "src/preview/ServerBrowserPage.test.ts": 2.7, + "src/preview/ServerBrowserPage.test.ts": 1.2, "src/preview/ServerBrowserStream.test.ts": 1.5, - "src/preview/SessionControl.test.ts": 0.1, - "src/process/agentScope.test.ts": 0.4, - "src/process/externalLauncher.test.ts": 2.7, - "src/processRunner.test.ts": 0.7, "src/project/AgentSessionImporter.test.ts": 1.5, - "src/project/AgentSessionJson.test.ts": 0.2, - "src/project/AgentSessionScanner.test.ts": 5.9, - "src/project/ManagedProjectFolders.test.ts": 2.2, + "src/project/AgentSessionScanner.test.ts": 0.8, + "src/project/ManagedProjectFolders.test.ts": 1.7, "src/project/ProjectCloneTracker.test.ts": 0.5, - "src/project/ProjectEnrichmentService.test.ts": 0.4, - "src/project/ProjectFaviconResolver.test.ts": 0.4, - "src/project/ProjectMutation.test.ts": 1.0, "src/project/ProjectService.deletion.test.ts": 0.7, "src/project/ProjectService.test.ts": 1.5, - "src/project/ProjectSetupScriptRunner.test.ts": 1.1, - "src/project/RepositoryIdentityResolver.test.ts": 0.5, - "src/project/T3ProjectFileLoader.test.ts": 0.3, "src/project/WorktreeSetupTracker.test.ts": 0.6, - "src/project/http.test.ts": 1.5, - "src/provider/AcpRegistryProbe.test.ts": 1.4, - "src/provider/AntigravityAuth.test.ts": 0.4, - "src/provider/AntigravityInstallation.test.ts": 0.5, - "src/provider/AntigravityProvider.test.ts": 0.4, + "src/provider/AcpRegistryProbe.test.ts": 0.9, "src/provider/ClaudeCapabilitiesProbe.test.ts": 0.7, - "src/provider/ClaudeModelCatalog.test.ts": 0.3, - "src/provider/CodexAuthCallback.test.ts": 0.3, - "src/provider/CodexChatGptAuth.test.ts": 7.1, - "src/provider/CodexChatGptModels.test.ts": 0.2, + "src/provider/CodexChatGptAuth.test.ts": 3.6, "src/provider/CodexChatGptSessionLock.test.ts": 0.5, - "src/provider/CodexDeveloperInstructions.test.ts": 0.1, - "src/provider/CodexInstallation.test.ts": 0.9, - "src/provider/CodexManagedErrors.test.ts": 0.2, - "src/provider/CodexManagedRuntime.test.ts": 0.4, - "src/provider/CodexMcpElicitation.test.ts": 0.1, - "src/provider/CodexProvider.catalog.test.ts": 1.1, "src/provider/CodexProvider.test.ts": 0.5, "src/provider/CodexThreadRevert.test.ts": 0.6, - "src/provider/Drivers/AntigravityDriver.test.ts": 3.0, - "src/provider/Drivers/AntigravitySkills.test.ts": 0.3, - "src/provider/Drivers/ClaudeExecutable.test.ts": 0.2, - "src/provider/Drivers/ClaudeHome.test.ts": 0.2, - "src/provider/Drivers/ClaudeSkillDispatch.test.ts": 0.1, - "src/provider/Drivers/ClaudeSkills.test.ts": 0.2, + "src/provider/Drivers/AntigravityDriver.test.ts": 1.2, "src/provider/Drivers/CodexDriver.test.ts": 0.8, - "src/provider/Drivers/CodexHomeLayout.test.ts": 0.4, - "src/provider/Drivers/antigravityUsageReader.test.ts": 0.3, - "src/provider/Drivers/claudeUsage.test.ts": 0.1, - "src/provider/Drivers/codexUsage.test.ts": 0.3, - "src/provider/EventNdjsonLogger.test.ts": 0.4, - "src/provider/GrokProvider.test.ts": 1.8, + "src/provider/GrokProvider.test.ts": 1.2, "src/provider/ModelManifest.test.ts": 0.6, - "src/provider/OpenCode2Client.events.test.ts": 0.4, - "src/provider/ProjectSettingsOverrideSweep.test.ts": 0.4, "src/provider/ProviderAuthService.test.ts": 0.5, - "src/provider/ProviderCredentialStore.test.ts": 0.4, - "src/provider/ProviderInstanceRegistry.test.ts": 10.7, + "src/provider/ProviderInstanceRegistry.test.ts": 1, "src/provider/ProviderInstanceRegistryHydration.test.ts": 1.1, - "src/provider/ProviderRegistry.test.ts": 1.3, - "src/provider/TurnTokenUsage.test.ts": 0.2, - "src/provider/acp/AcpJsonRpcConnection.test.ts": 11.1, - "src/provider/acp/AcpSessionRuntime.processTree.test.ts": 3.7, - "src/provider/acp/AntigravityAcpSupport.test.ts": 0.4, - "src/provider/acp/AntigravityProtocol.test.ts": 0.2, - "src/provider/antigravityAuthSupport.test.ts": 0.7, - "src/provider/antigravityCallback.test.ts": 0.3, + "src/provider/ProviderRegistry.test.ts": 0.7, + "src/provider/acp/AcpJsonRpcConnection.test.ts": 6.1, + "src/provider/acp/AcpSessionRuntime.processTree.test.ts": 1, "src/provider/antigravityUsageLimits.test.ts": 0.6, - "src/provider/claudeResetCredits.test.ts": 0.2, - "src/provider/claudeUsageLimits.test.ts": 0.1, - "src/provider/codexLaunchArgs.test.ts": 0.1, - "src/provider/codexModelCatalog.test.ts": 0.3, - "src/provider/codexUsageLimits.test.ts": 0.2, "src/provider/providerCompatibility.test.ts": 1.6, "src/provider/providerInstallation.test.ts": 1.1, "src/provider/providerMaintenanceRunner.test.ts": 0.5, - "src/provider/providerStatusCache.test.ts": 0.3, - "src/provider/resetCreditCoordinator.test.ts": 0.1, - "src/provider/userInputAttachments.test.ts": 0.3, - "src/pullRequest/PullRequestProviderRateLimit.test.ts": 0.4, - "src/pullRequest/PullRequestReadCache.test.ts": 0.4, - "src/pullRequest/PullRequestReviewGuideService.test.ts": 4.0, - "src/pullRequest/PullRequestService.test.ts": 8.7, - "src/pullRequest/linkedThreads.test.ts": 0.4, - "src/pullRequest/pullRequestReviewGuideInput.test.ts": 0.1, - "src/pullRequest/pullRequestReviewGuideNormalize.test.ts": 0.1, + "src/pullRequest/PullRequestReviewGuideService.test.ts": 0.7, + "src/pullRequest/PullRequestService.test.ts": 4.6, "src/pullRequest/pullRequestSyncKey.test.ts": 0.7, "src/relay/AgentAwarenessRelay.test.ts": 1.6, - "src/resourceTelemetry/DesktopTelemetryReceiver.test.ts": 0.3, - "src/resourceTelemetry/Model.test.ts": 0.3, - "src/resourceTelemetry/NativeTelemetryClient.test.ts": 0.4, - "src/resourceTelemetry/ResourceMonitorBinary.test.ts": 0.3, "src/resourceTelemetry/ResourceTelemetry.test.ts": 0.5, - "src/resourceTelemetry/ResourceTelemetryHistory.test.ts": 0.2, - "src/review/ReviewService.test.ts": 0.4, - "src/rpcInitialItems.memory.test.ts": 8.1, - "src/scheduledTasks/Schedule.test.ts": 0.3, "src/scheduledTasks/ScheduledTaskService.schedule.test.ts": 1.5, "src/scheduledTasks/ScheduledTaskService.test.ts": 1.5, - "src/scheduledTasks/ScheduledTaskService.webhook.test.ts": 2.6, + "src/scheduledTasks/ScheduledTaskService.webhook.test.ts": 1.5, "src/scheduledTasks/webhookRoute.test.ts": 1.4, - "src/scheduledTasks/webhookTemplate.test.ts": 0.1, - "src/scheduledTasks/webhookVerification.test.ts": 0.2, - "src/scheduling/Scheduler.integration.test.ts": 1.5, - "src/scheduling/Scheduler.test.ts": 0.2, "src/secrets/SecretRequests.test.ts": 2.6, - "src/serverActivation.test.ts": 0.2, - "src/serverLifecycleEvents.test.ts": 0.4, - "src/serverLogger.test.ts": 0.3, "src/serverRuntimeStartup.autoPull.test.ts": 1.3, "src/serverRuntimeStartup.test.ts": 1.3, "src/serverRuntimeState.test.ts": 1.1, - "src/serverSettings.test.ts": 3.0, + "src/serverSettings.test.ts": 1.6, "src/serviceLauncher.test.ts": 11.2, "src/sourceControl/BitbucketApi.test.ts": 0.6, - "src/sourceControl/SourceControlDiscovery.test.ts": 0.6, "src/sourceControl/SourceControlProviderRegistry.test.ts": 0.5, - "src/sourceControl/SourceControlRepositoryService.test.ts": 0.4, - "src/sourceControl/gitHubChangeRequestTemplate.test.ts": 1.0, - "src/sourceControl/githubStackRebase.test.ts": 2.9, + "src/sourceControl/githubStackRebase.test.ts": 1.5, "src/startupAccess.test.ts": 0.5, - "src/storageCleanup.test.ts": 2.8, - "src/telemetry/AnalyticsService.test.ts": 0.4, - "src/telemetry/Identify.test.ts": 0.4, - "src/terminal/Manager.test.ts": 4.7, - "src/terminal/NodePtyAdapter.test.ts": 0.2, + "src/storageCleanup.test.ts": 1.4, + "src/terminal/Manager.test.ts": 0.9, "src/terminal/OutputProtocol.test.ts": 0.9, - "src/testUtils/weightedShardSequencer.test.ts": 0.3, "src/textGeneration/AntigravityTextGeneration.test.ts": 0.5, - "src/textGeneration/ClaudeTextGeneration.test.ts": 0.9, - "src/textGeneration/CodexTextGeneration.test.ts": 1.6, - "src/textGeneration/GrokTextGeneration.test.ts": 1.3, + "src/textGeneration/CodexTextGeneration.test.ts": 1, + "src/textGeneration/GrokTextGeneration.test.ts": 0.5, "src/textGeneration/OpenCode2TextGeneration.test.ts": 1.4, "src/textGeneration/TextGeneration.test.ts": 1.2, "src/textGeneration/ThreadTitleLinks.test.ts": 0.5, "src/usage/UsageLimitSources.test.ts": 0.6, - "src/usage/UsageService.test.ts": 2.8, + "src/usage/UsageService.test.ts": 2.2, "src/usage/cliproxyApi.test.ts": 0.5, - "src/usage/usageAggregation.test.ts": 0.2, - "src/usage/usagePricing.test.ts": 0.4, - "src/usage/usageScanCache.test.ts": 0.3, - "src/usage/usageTranscriptReader.test.ts": 0.3, "src/usage/usageTranscriptStreaming.test.ts": 1.1, - "src/utils/subscribeBeforeSnapshot.test.ts": 0.1, - "src/vcs/GitVcsDriver.test.ts": 6.5, - "src/vcs/GitVcsDriverCore.test.ts": 13.6, - "src/vcs/VcsDriverRegistry.test.ts": 0.3, - "src/vcs/VcsProcess.test.ts": 0.5, + "src/vcs/GitVcsDriver.test.ts": 2.8, + "src/vcs/GitVcsDriverCore.test.ts": 7.2, "src/vcs/VcsProjectConfig.test.ts": 0.7, - "src/vcs/VcsProvisioningService.test.ts": 0.4, "src/vcs/VcsStatusBroadcaster.test.ts": 1.3, - "src/workspace/WorkspaceEntries.test.ts": 2.5, - "src/workspace/WorkspaceFileSystem.test.ts": 0.4, - "src/workspace/WorkspacePaths.test.ts": 0.2, - "src/workspace/WorkspaceSearchIndex.test.ts": 0.2, - "src/workspace/fileHeaderMimeType.test.ts": 0.1, + "src/workspace/WorkspaceEntries.test.ts": 0.8, "src/ws.test.ts": 1.9 } diff --git a/apps/server/src/testUtils/weightedShardSequencer.test.ts b/apps/server/src/testUtils/weightedShardSequencer.test.ts deleted file mode 100644 index 4d898a1bb9d9..000000000000 --- a/apps/server/src/testUtils/weightedShardSequencer.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import type { TestSpecification, Vitest } from "vite-plus/test/node"; - -import shardWeights from "./shardWeights.json" with { type: "json" }; -import { WeightedShardSequencer } from "./weightedShardSequencer.ts"; - -const root = "/repo/apps/server"; -const recordedSeconds: Readonly> = shardWeights; -const recorded = Object.keys(recordedSeconds); -const files = [...recorded, ...Array.from({ length: 300 }, (_, i) => `src/fast${i}.test.ts`)]; -const specs = files.map((file) => ({ moduleId: `${root}/${file}` }) as TestSpecification); - -const shardModuleIds = (count: number) => - Promise.all( - Array.from({ length: count }, async (_, i) => { - const ctx = { config: { root, shard: { index: i + 1, count } } } as unknown as Vitest; - const shard = await new WeightedShardSequencer(ctx).shard(specs); - return shard.map((spec) => spec.moduleId); - }), - ); - -describe("WeightedShardSequencer", () => { - it("runs every file in exactly one shard", async () => { - const shards = await shardModuleIds(6); - - expect(shards.flat().toSorted()).toEqual(specs.map((spec) => spec.moduleId).toSorted()); - }); - - it("puts each of the slowest files in a different shard", async () => { - const slowest = new Set( - recorded - .toSorted((a, b) => (recordedSeconds[b] ?? 0) - (recordedSeconds[a] ?? 0)) - .slice(0, 6) - .map((file) => `${root}/${file}`), - ); - const shards = await shardModuleIds(6); - - for (const shard of shards) { - expect(shard.filter((moduleId) => slowest.has(moduleId))).toHaveLength(1); - } - }); -}); diff --git a/apps/server/src/testkit/RpcInitialMemory.fixture.mjs b/apps/server/src/testkit/RpcInitialMemory.fixture.mjs deleted file mode 100644 index a9648abf6715..000000000000 --- a/apps/server/src/testkit/RpcInitialMemory.fixture.mjs +++ /dev/null @@ -1,130 +0,0 @@ -import * as NodeModule from "node:module"; -import * as NodeURL from "node:url"; -import * as NodeCrypto from "node:crypto"; -import * as NodeAssert from "node:assert/strict"; - -const root = process.argv[2]; -const mode = process.argv[3]; -const count = Number(process.argv[4] ?? 4); -const bytes = Number(process.argv[5] ?? 32768); -const require = NodeModule.createRequire(root + "/apps/server/package.json"); -const load = (name) => import(NodeURL.pathToFileURL(require.resolve("effect/" + name))); -const [Effect, Stream, Layer, Deferred, Fiber] = await Promise.all( - ["Effect", "Stream", "Layer", "Deferred", "Fiber"].map(load), -); -const app = (file) => import(NodeURL.pathToFileURL(root + "/apps/server/src/" + file + ".ts")); -const [Prefix, Ws, Threads, Events] = await Promise.all([ - app("rpcInitialItems"), - app("ws"), - app("orchestration-v2/ThreadManagementService"), - app("persistence/OrchestrationEventStore"), -]); -const refs = []; -const fibers = []; -const checkpoints = []; -const deliveredCounts = []; - -function history() { - const value = JSON.parse(JSON.stringify({ text: NodeCrypto.randomBytes(bytes).toString("hex") })); - refs.push(new WeakRef(value)); - return value; -} - -function start(ready) { - const index = deliveredCounts.push(0) - 1; - const live = Stream.fromEffect(Deferred.succeed(ready, undefined)).pipe( - Stream.drain, - Stream.concat(Stream.never), - ); - const dependencies = Layer.mergeAll( - Layer.mock(Threads.ThreadManagementService)({ - ensureLegacyTranscript: () => Effect.void, - getThreadSnapshot: () => - Effect.sync(() => ({ - snapshotSequence: 1, - projection: { - messages: [history()], - contextHandoffs: [], - turnItems: [], - visibleTurnItems: [], - }, - })), - streamStoredEventsFrom: () => live, - }), - Layer.mock(Events.OrchestrationEventStore)({ - latestAgentSequence: () => Effect.succeed(1), - getAgentReplayStats: () => - Effect.succeed({ - eventCount: 1, - rawPayloadBytes: bytes * 2, - hasCreateEvent: false, - }), - readAgentEvents: () => - Stream.fromEffect( - Effect.sync(() => ({ - sequence: 1, - event: { type: "message.updated", payload: history() }, - })), - ), - }), - ); - const stream = - mode === "prefix" - ? Effect.succeed(Stream.concat(Prefix.rpcInitialItems([history()]), live)) - : Ws.subscribeOrchestrationV2Thread({ - threadId: "synthetic-thread", - requestCompletionMarker: true, - ...(mode === "replay" ? { afterSequence: 0 } : {}), - }).pipe(Effect.provide(dependencies)); - let delivered = 0; - return stream.pipe( - Effect.flatMap((stream) => - stream.pipe( - Stream.runForEach((item) => - Effect.sync(() => { - if (mode === "prefix") { - NodeAssert.equal(item.text.length, bytes * 2); - } else if (delivered === 0) { - NodeAssert.equal(item.kind, mode === "replay" ? "event" : "snapshot"); - const value = mode === "replay" ? item.event.payload : item.projection.messages[0]; - NodeAssert.equal(value.text.length, bytes * 2); - } else { - NodeAssert.equal(item.kind, "synchronized"); - } - delivered++; - deliveredCounts[index] = delivered; - }), - ), - Effect.forkDetach, - ), - ), - ); -} - -const result = await Effect.runPromise( - Effect.gen(function* () { - for (let i = 0; i < count; i++) { - const ready = yield* Deferred.make(); - fibers.push(yield* start(ready)); - yield* Deferred.await(ready); - NodeAssert.equal(deliveredCounts[i], mode === "prefix" ? 1 : 2); - if (i === Math.floor(count / 2) - 1 || i === count - 1) { - yield* Effect.promise(async () => { - for (let j = 0; j < 3; j++) { - await new Promise(setImmediate); - global.gc(); - } - checkpoints.push({ - subscriptions: fibers.length, - retained: refs.filter((ref) => ref.deref()).length, - payloadMiB: ((i + 1) * bytes * 2) / 1048576, - heapMiB: Math.round(process.memoryUsage().heapUsed / 1048576), - }); - }); - } - } - for (const fiber of fibers) yield* Fiber.interrupt(fiber); - return { checkpoints }; - }), -); -console.log(JSON.stringify(result)); diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts index 454c683896d2..395a24eeedc8 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts @@ -15,8 +15,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; -import { type AcpError, AcpRequestError } from "effect-acp/errors"; +import { type AcpError } from "effect-acp/errors"; import type * as AcpSchema from "effect-acp/compat"; import { expect } from "vite-plus/test"; @@ -380,29 +379,6 @@ it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { }).pipe(Effect.scoped), ); - it.effect("declines elicitation and stops the helper", () => - Effect.gen(function* () { - const fixture = yield* makeFixture({ prompt: () => Effect.never }); - const child = yield* fixture.textGeneration - .generateThreadTitle(fixture.titleInput) - .pipe(Effect.forkChild); - yield* Deferred.await(fixture.enteredPrompt); - const reply = yield* fixture.incoming.question( - { - sessionId: SESSION_ID, - mode: "form", - message: "Name this branch", - requestedSchema: { type: "object", properties: {} }, - }, - requestContext, - ); - const error = yield* Fiber.join(child).pipe(Effect.flip); - expect(reply).toEqual({ action: "decline" }); - expect(error.detail).toContain("user input"); - yield* fixture.assertCleaned; - }).pipe(Effect.scoped), - ); - it.effect.each(["file", "terminal", "extension"] as const)( "denies unexpected %s requests", (kind) => @@ -490,21 +466,6 @@ it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { }).pipe(Effect.scoped), ); - it.effect("fails an unavailable model before prompting", () => - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const error = yield* fixture.textGeneration - .generateThreadTitle({ - ...fixture.titleInput, - modelSelection: { ...modelSelection, model: "not-in-the-account" }, - }) - .pipe(Effect.flip); - expect(error.detail).toContain("select the Antigravity model"); - expect(fixture.state.prompts).toEqual([]); - yield* fixture.assertCleaned; - }).pipe(Effect.scoped), - ); - it.effect("uses the native default without sending T3's default selection as a model ID", () => Effect.gen(function* () { const fixture = yield* makeFixture(); @@ -550,21 +511,6 @@ it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { }).pipe(Effect.scoped), ); - it.effect("does not start another runtime or prompt after authentication fails", () => - Effect.gen(function* () { - const fixture = yield* makeFixture({ - startError: new AcpRequestError({ code: -32000, errorMessage: "Authentication required" }), - }); - const error = yield* fixture.textGeneration - .generateThreadTitle(fixture.titleInput) - .pipe(Effect.flip); - expect(error._tag).toBe("TextGenerationError"); - expect(fixture.state.workspaces).toHaveLength(1); - expect(fixture.state.prompts).toEqual([]); - yield* fixture.assertCleaned; - }).pipe(Effect.scoped), - ); - it.effect("does not launch during sign-out or before sign-in", () => Effect.gen(function* () { const fixture = yield* makeFixture({ rejectAdmission: true }); @@ -577,21 +523,6 @@ it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { }).pipe(Effect.scoped), ); - it.effect("times out a stalled helper and waits for cleanup", () => - Effect.gen(function* () { - const fixture = yield* makeFixture({ prompt: () => Effect.never }); - const child = yield* fixture.textGeneration - .generateThreadTitle(fixture.titleInput) - .pipe(Effect.forkChild); - yield* Deferred.await(fixture.enteredPrompt); - yield* TestClock.adjust(180_000); - const error = yield* Fiber.join(child).pipe(Effect.flip); - expect(error.detail).toContain("timed out"); - expect(fixture.state.cancellations).toBe(1); - yield* fixture.assertCleaned; - }).pipe(Effect.scoped), - ); - it.effect.each(["caller", "sign-out"] as const)( "closes all helper resources when stopped by %s", (source) => diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index f2b94aeb933e..7a12b47b2f9e 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -11,12 +11,12 @@ import * as Schema from "effect/Schema"; import { expect } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; +import { processProbeSource } from "../testUtils/processProbe.ts"; import { SYNTHETIC_CLAUDE_CAPABLE_MODEL, SYNTHETIC_CLAUDE_COLLIDING_ALIAS, SYNTHETIC_CLAUDE_MODEL_CATALOG, SYNTHETIC_CLAUDE_STANDARD_MODEL, - SYNTHETIC_CLAUDE_THINKING_MODEL, } from "../provider/ClaudeModelCatalog.testFixtures.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeThreadTitle } from "@t3tools/provider-core/server/textGenerationUtils"; @@ -31,7 +31,7 @@ const layerClaudeTextGenerationTest = ServerConfig.ServerConfig.layerTest(proces // The stub behaviour lives in Node so the same implementation runs on Windows, // where a shebang file is not executable and would fall through to the real // Claude CLI on PATH; `writeFakeCli` picks the launcher shape per host. -function makeFakeClaudeBinary(dir: string) { +function makeFakeClaudeBinary(dir: string, lifecyclePort?: number) { return Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; @@ -45,7 +45,10 @@ function makeFakeClaudeBinary(dir: string) { directory: binDir, name: "claude", platform, - source, + source: + lifecyclePort === undefined + ? source + : `process.on("SIGTERM", () => {}); ${processProbeSource(lifecyclePort)}`, }); return binDir; }); @@ -54,6 +57,7 @@ function makeFakeClaudeBinary(dir: string) { function withFakeClaudeEnv( input: { output: string; + lifecyclePort?: number; exitCode?: number; stderr?: string; argsMustContain?: string; @@ -68,7 +72,7 @@ function withFakeClaudeEnv( return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); - const binDir = yield* makeFakeClaudeBinary(tempDir); + const binDir = yield* makeFakeClaudeBinary(tempDir, input.lifecyclePort); const pathDelimiter = (yield* HostProcess.isWindows) ? ";" : ":"; const previousPath = process.env.PATH; const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; @@ -192,79 +196,6 @@ function withFakeClaudeEnv( } it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { - it.effect("forwards Claude thinking settings without passing unsupported effort", () => - withFakeClaudeEnv( - { - output: JSON.stringify({ - structured_output: { - subject: "Add important change", - body: "", - }, - }), - argsMustContain: '--settings {"disableAllHooks":true,"alwaysThinkingEnabled":false}', - argsMustNotContain: "--effort", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/claude-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: { - ...createModelSelection( - ProviderInstanceId.make("claudeAgent"), - SYNTHETIC_CLAUDE_THINKING_MODEL, - [ - { id: "thinking", value: false }, - { id: "effort", value: "high" }, - ], - ), - }, - }); - - expect(generated.subject).toBe("Add important change"); - }), - ), - ); - - it.effect("keeps a configured custom alias opaque to the Claude CLI", () => - withFakeClaudeEnv( - { - output: JSON.stringify({ - structured_output: { - title: "Keep custom model", - body: "", - }, - }), - argsMustContain: `--model ${SYNTHETIC_CLAUDE_COLLIDING_ALIAS} --effort max --settings`, - claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] }, - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generatePrContent({ - cwd: process.cwd(), - baseBranch: "main", - headBranch: "feature/custom-model", - commitSummary: "Keep custom model", - diffSummary: "1 file changed", - diffPatch: "diff --git a/README.md b/README.md", - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - SYNTHETIC_CLAUDE_COLLIDING_ALIAS, - [ - { id: "effort", value: "max" }, - { id: "fastMode", value: true }, - { id: "contextWindow", value: "expanded" }, - ], - ), - }); - - expect(generated.title).toBe("Keep custom model"); - }), - ), - ); - it.effect( "keeps canonical built-in capabilities when a custom model collides with its alias", () => @@ -339,28 +270,6 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { ), ); - it.effect("generates branch names from skill prompts without executable capabilities", () => - withFakeClaudeEnv( - { - output: JSON.stringify({ structured_output: { branch: "call-script" } }), - stdinMustContain: "/call-script", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "/call-script", - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: SYNTHETIC_CLAUDE_STANDARD_MODEL, - }, - }); - - expect(generated.branch).toBe("call-script"); - }), - ), - ); - it.effect("runs Claude text generation with the configured CLAUDE_CONFIG_DIR", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -520,29 +429,4 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { }), ), ); - - it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => - withFakeClaudeEnv( - { - output: JSON.stringify({ - structured_output: { - title: ' """ """ ', - }, - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread.", - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: SYNTHETIC_CLAUDE_STANDARD_MODEL, - }, - }); - - expect(generated.title).toBe("New thread"); - }), - ), - ); }); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 8da15d127b5e..b20e07e65e1b 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -14,6 +14,7 @@ import { toJsonSchemaObject, } from "@t3tools/provider-core/server/textGenerationUtils"; import { + getModelSelectionBooleanOptionValue, getModelSelectionStringOptionValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; @@ -167,8 +168,11 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu const ultracode = isClaudeCatalogUltracodeEffort(resolvedEffort); const thinkingDescriptor = findDescriptor("thinking"); const fastModeDescriptor = findDescriptor("fastMode"); + // Only a chosen value; otherwise Claude Code's own default applies, as in chat turns. const thinking = - thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined; + thinkingDescriptor?.type === "boolean" + ? getModelSelectionBooleanOptionValue(resolvedModelSelection, "thinking") + : undefined; const fastMode = fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined; const settings = { @@ -234,6 +238,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu stdin: { stream: Stream.encodeText(Stream.make(prompt)), }, + forceKillAfter: "1 second", }); const child = yield* commandSpawner diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 7eab8ee7472d..b57ed29988c7 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -1,19 +1,21 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vite-plus/test"; -import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; +import { CodexSettings, ProviderInstanceId } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; +import { makeProcessProbe, processProbeSource } from "../testUtils/processProbe.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; @@ -30,6 +32,7 @@ const layerCodexTextGenerationTest = ServerConfig.ServerConfig.layerTest(process interface FakeCodexInput { output: string; + lifecycle?: { port: number; command: "exec" | "mcp list" }; exitCode?: number; stderr?: string; requireImage?: boolean; @@ -67,7 +70,17 @@ function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { 'import * as NodeFS from "node:fs";', `const check = ${check};`, "const args = process.argv.slice(2);", - 'if (args.includes("mcp") && args.includes("list")) {', + 'const isMcpList = args.includes("mcp") && args.includes("list");', + ...(input.lifecycle === undefined + ? [] + : [ + `if (isMcpList === ${input.lifecycle.command === "mcp list"}) {`, + 'process.on("SIGTERM", () => {});', + processProbeSource(input.lifecycle.port), + "await new Promise(() => {});", + "}", + ]), + "if (isMcpList) {", ' if (typeof check.mcpServers === "number") process.exit(check.mcpServers);', " process.stdout.write(JSON.stringify(check.mcpServers));", " process.exit(0);", @@ -173,6 +186,34 @@ function withFakeCodexEnv( } it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { + it.effect.each(["exec", "mcp list"] as const)( + "kills a stalled %s CLI when its request is interrupted", + (command) => + Effect.gen(function* () { + const probe = yield* makeProcessProbe; + yield* withFakeCodexEnv( + { output: "", lifecycle: { port: probe.port, command } }, + (textGeneration) => + Effect.gen(function* () { + const fiber = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.forkScoped); + yield* probe.started; + + const interrupted = yield* Fiber.interrupt(fiber).pipe(Effect.forkScoped); + yield* probe.exited; + yield* Fiber.join(interrupted); + const exit = yield* Fiber.await(fiber); + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true); + }), + ); + }), + ); + it.effect.each(["gpt-5.6-luna", "openai.gpt-5.6-luna"])( "dispatches the qualified live model for %s", (selectedModel) => @@ -194,60 +235,6 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { }), ), ); - it.effect("generates and sanitizes commit messages without branch by default", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - subject: - " Add important change to the system with too much detail and a trailing period.\nsecondary line", - body: "\n- added migration\n- updated tests\n", - }), - stdinMustNotContain: "branch must be a short semantic git branch fragment", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.subject.length).toBeLessThanOrEqual(72); - expect(generated.subject.endsWith(".")).toBe(false); - expect(generated.body).toBe("- added migration\n- updated tests"); - expect(generated.branch).toBeUndefined(); - }), - ), - ); - - it.effect( - "forwards codex service tier and non-default reasoning effort into codex exec config", - () => - withFakeCodexEnv( - { - output: JSON.stringify({ - subject: "Add important change", - body: "", - }), - requireServiceTier: "priority", - requireReasoningEffort: "xhigh", - stdinMustNotContain: "branch must be a short semantic git branch fragment", - }, - (textGeneration) => - textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ - { id: "reasoningEffort", value: "xhigh" }, - { id: "serviceTier", value: "priority" }, - ]), - }), - ), - ); it.effect("omits a persisted service tier for managed ChatGPT text generation", () => withFakeCodexEnv( @@ -355,166 +342,6 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { ), ); - it.effect("uses T3CODE_CODEX_LAUNCH_ARGS for codex exec over settings", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - subject: "Add important change", - body: "", - }), - launchArgs: "--enable settings-feature", - environment: { ...process.env, T3CODE_CODEX_LAUNCH_ARGS: " --strict-config --listen off " }, - requireArg: "--strict-config", - forbidArg: "settings-feature", - }, - (textGeneration) => - textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }), - ), - ); - - it.effect("defaults git text generation codex effort to low", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - subject: "Add important change", - body: "", - }), - requireReasoningEffort: "low", - }, - (textGeneration) => - textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }), - ), - ); - - it.effect("generates commit message with branch when includeBranch is true", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - subject: "Add important change", - body: "", - branch: "fix/important-system-change", - }), - stdinMustContain: "branch must be a short semantic git branch fragment", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - includeBranch: true, - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.subject).toBe("Add important change"); - expect(generated.branch).toBe("fix/important-system-change"); - }), - ), - ); - - it.effect("generates PR content and trims markdown body", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: " Improve orchestration flow\nwith ignored suffix", - body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n", - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generatePrContent({ - cwd: process.cwd(), - baseBranch: "main", - headBranch: "feature/codex-effect", - commitSummary: "feat: improve orchestration flow", - diffSummary: "2 files changed", - diffPatch: "diff --git a/a.ts b/a.ts", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.title).toBe("Improve orchestration flow"); - expect(generated.body.startsWith("## Summary")).toBe(true); - expect(generated.body.endsWith("\n\n")).toBe(false); - }), - ), - ); - - it.effect("generates branch names and normalizes branch fragments", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - branch: " Feat/Session ", - }), - stdinMustNotContain: "Image attachments supplied to the model", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "Please update session handling.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.branch).toBe("feat/session"); - }), - ), - ); - - it.effect.each([ - { - mode: "static", - output: "Add Search", - expected: "team/add-search", - instruction: "without a prefix or namespace", - }, - { - mode: "semantic", - output: "feat/add-search", - expected: "feat/add-search", - instruction: "semantic prefix", - }, - { - mode: "custom", - output: "Julius/ABC-123.v2", - expected: "Julius/ABC-123.v2", - instruction: "Preserve the issue ID and capitalization.", - }, - ] as const)("generates a branch using $mode naming", (example) => - withFakeCodexEnv( - { - output: JSON.stringify({ branch: example.output }), - stdinMustContain: example.instruction, - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "Add search", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - naming: { - mode: example.mode, - prefix: "team/", - instructions: "Preserve the issue ID and capitalization.", - }, - }); - expect(generated.branch).toBe(example.expected); - }), - ), - ); - it.effect("generates branch names even when the ambient scope is already closed", () => withFakeCodexEnv( { @@ -543,29 +370,6 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { ), ); - it.effect("generates thread titles and trims them for sidebar use", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: - ' "Investigate websocket reconnect regressions after worktree restore" \nignored line', - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Please investigate websocket reconnect regressions after a worktree restore.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.title).toBe( - "Investigate websocket reconnect regressions after worktree restore", - ); - }), - ), - ); - it.effect("returns the refinement signal for an unresolved subject", () => withFakeCodexEnv( { output: JSON.stringify({ title: "Investigate issue", needsRefinement: true }) }, @@ -582,106 +386,6 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { ), ); - it.effect("falls back when thread title normalization becomes whitespace-only", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: ' """ """ ', - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.title).toBe("New thread"); - }), - ), - ); - - it.effect("trims whitespace exposed after quote removal in thread titles", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: ` "' hello world '" `, - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.title).toBe("hello world"); - }), - ), - ); - - it.effect("omits attachment metadata section when no attachments are provided", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - branch: "fix/session-timeout", - }), - stdinMustNotContain: "Attachment metadata:", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "Fix timeout behavior.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.branch).toBe("fix/session-timeout"); - }), - ), - ); - - it.effect("passes image attachments through as codex image inputs", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - branch: "fix/ui-regression", - }), - requireImage: true, - stdinMustContain: "Attachment metadata:", - }, - (textGeneration) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig.ServerConfig; - const attachmentId = "thread-branch-image-attachment"; - const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); - yield* fs.makeDirectory(attachmentsDir, { recursive: true }); - yield* fs.writeFile(attachmentPath, Buffer.from("hello")); - - const generated = yield* textGeneration.generateBranchName({ - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - cwd: process.cwd(), - message: "Fix layout bug from screenshot.", - attachments: [ - { - type: "image", - id: attachmentId, - name: "bug.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - }); - - expect(generated.branch).toBe("fix/ui-regression"); - }), - ), - ); - it.effect("resolves persisted attachment ids to files for codex image inputs", () => withFakeCodexEnv( { @@ -730,105 +434,4 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { }), ), ); - - it.effect("ignores missing attachment ids for codex image inputs", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - branch: "fix/ui-regression", - }), - requireImage: true, - }, - (textGeneration) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig.ServerConfig; - const missingAttachmentId = "thread-missing-attachment"; - const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); - yield* fs.remove(missingPath).pipe(Effect.ignore); - - const result = yield* textGeneration - .generateBranchName({ - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - cwd: process.cwd(), - message: "Fix layout bug from screenshot.", - attachments: [ - { - type: "image", - id: missingAttachmentId, - name: "outside.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - }) - .pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toBeInstanceOf(TextGenerationError); - expect(result.failure.message).toContain("missing --image input"); - } - }), - ), - ); - - it.effect( - "fails with typed TextGenerationError when codex returns wrong branch payload shape", - () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: "This is not a branch payload", - }), - }, - (textGeneration) => - Effect.gen(function* () { - const result = yield* textGeneration - .generateBranchName({ - cwd: process.cwd(), - message: "Fix websocket reconnect flake", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) - .pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toBeInstanceOf(TextGenerationError); - expect(result.failure.message).toContain("Codex returned invalid structured output"); - } - }), - ), - ); - - it.effect("returns typed TextGenerationError when codex exits non-zero", () => - withFakeCodexEnv( - { - output: JSON.stringify({ subject: "ignored", body: "" }), - exitCode: 1, - stderr: "codex execution failed", - }, - (textGeneration) => - Effect.gen(function* () { - const result = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-error", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) - .pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toBeInstanceOf(TextGenerationError); - expect(result.failure.message).toContain( - "Codex CLI command failed: codex execution failed", - ); - } - }), - ), - ); }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 6ee03420b7ab..20066232105f 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -178,6 +178,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func env: input.env, cwd: input.cwd, shell: spawnCommand.shell, + forceKillAfter: "1 second", }), ); const [stdout, , exitCode] = yield* Effect.all( @@ -319,6 +320,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func stdin: { stream: Stream.encodeText(Stream.make(prompt)), }, + forceKillAfter: "1 second", }); const child = yield* commandSpawner diff --git a/apps/server/src/textGeneration/GrokTextGeneration.test.ts b/apps/server/src/textGeneration/GrokTextGeneration.test.ts index 80f5c236259d..c74f77db4d06 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.test.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.test.ts @@ -132,93 +132,4 @@ it.layer(layerGrokTextGenerationTest)("GrokTextGeneration", (it) => { }), ), ); - - it.effect("surfaces ACP request failures as text generation errors", () => - withFakeAcpGrok( - { - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ branch: "unreachable" }), - }, - (textGeneration) => - Effect.gen(function* () { - const error = yield* Effect.flip( - textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "wire up grok", - modelSelection: createModelSelection( - ProviderInstanceId.make("grok"), - "missing-grok-model", - ), - }), - ); - expect(error._tag).toBe("TextGenerationError"); - expect(error.detail).toContain("Grok ACP base model"); - }), - ), - ); - - it.effect("fails with TextGenerationError when output is empty", () => - withFakeAcpGrok( - { - T3_ACP_PROMPT_RESPONSE_TEXT: " \n ", - }, - (textGeneration) => - Effect.gen(function* () { - const error = yield* Effect.flip( - textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "anything", - modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"), - }), - ); - expect(error._tag).toBe("TextGenerationError"); - expect(error.detail).toMatch(/empty/i); - }), - ), - ); - - it.effect("decodes a structured PR title + body", () => - withFakeAcpGrok( - { - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ - title: "feat(grok): wire up session/set_model", - body: "## Summary\n- Replace `-m` spawn flag with the typed ACP `session/set_model`.\n- Translate `MODEL_SWITCH_INCOMPATIBLE_AGENT` into a validation error.", - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generatePrContent({ - cwd: process.cwd(), - baseBranch: "main", - headBranch: "feat/grok-provider", - commitSummary: "feat: add grok provider", - diffSummary: "M apps/server/src/provider/Drivers/GrokDriver.ts", - diffPatch: "diff --git a/.../GrokDriver.ts b/.../GrokDriver.ts", - modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"), - }); - - expect(generated.title).toBe("feat(grok): wire up session/set_model"); - expect(generated.body).toContain("Translate `MODEL_SWITCH_INCOMPATIBLE_AGENT`"); - }), - ), - ); - - it.effect("fails with TextGenerationError when output is unparseable JSON", () => - withFakeAcpGrok( - { - T3_ACP_PROMPT_RESPONSE_TEXT: "totally not json output from a confused model", - }, - (textGeneration) => - Effect.gen(function* () { - const error = yield* Effect.flip( - textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "anything", - modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"), - }), - ); - expect(error._tag).toBe("TextGenerationError"); - expect(error.detail).toMatch(/invalid structured output/i); - }), - ), - ); }); diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts index 82c3bd165b19..775d2542d67e 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts @@ -37,69 +37,6 @@ it.layer(layer)("OpenCode2TextGeneration", (it) => { }).pipe(Effect.scoped), ); - it.effect("fails at once when the event stream drops before the reply", () => - Effect.gen(function* () { - const [subscribe, connected, create, created] = OPENCODE2_TITLE_GENERATION; - const sessionId = "ses_f0ec85cf6ffebfalpvV2E9nocu"; - const server = yield* OpenCode2AdapterV2Testkit.replayServer({ - provider: "opencode", - protocol: OpenCode2AdapterV2Testkit.OPENCODE2_HTTP_PROTOCOL, - version: "2.0.18", - scenario: "opencode2_title_generation_stream_lost", - entries: [ - subscribe!, - connected!, - create!, - created!, - { - type: "expect_outbound", - frame: { type: "session.prompt", input: { sessionID: sessionId, text: "" } }, - }, - { - type: "emit_inbound", - frame: { - type: "sdk.response", - operation: "session.prompt", - data: { - data: { - id: "msg_1", - sessionID: sessionId, - time: { created: 1 }, - type: "user", - payload: { text: "t" }, - delivery: "steer", - }, - }, - }, - }, - { type: "runtime_exit", status: "success" }, - { - type: "expect_outbound", - frame: { type: "session.remove", input: { sessionID: sessionId } }, - }, - { - type: "emit_inbound", - frame: { type: "sdk.response", operation: "session.remove", data: null }, - }, - ], - }); - const textGeneration = yield* OpenCode2TextGeneration.make().pipe( - Effect.provideService(OpenCode2Server.OpenCode2Server, server), - ); - const failure = yield* textGeneration - .generateThreadTitle({ - cwd: process.cwd(), - message: "fix the login redirect loop after oauth", - modelSelection: { - instanceId: ProviderInstanceId.make("opencode"), - model: "opencode/big-pickle", - }, - }) - .pipe(Effect.flip, Effect.timeout("10 seconds")); - assert.equal(failure._tag, "TextGenerationError"); - }).pipe(Effect.scoped), - ); - it.effect("keeps a provider's own error message out of the caller-visible detail", () => Effect.gen(function* () { const [subscribe, connected, create, created] = OPENCODE2_TITLE_GENERATION; @@ -182,81 +119,4 @@ it.layer(layer)("OpenCode2TextGeneration", (it) => { assert.deepEqual(failure.cause, refusal); }).pipe(Effect.scoped), ); - - it.effect("ends at once on an execution end this build cannot decode", () => - Effect.gen(function* () { - const [subscribe, connected, create, created] = OPENCODE2_TITLE_GENERATION; - const sessionId = "ses_f0ec85cf6ffebfalpvV2E9nocu"; - const server = yield* OpenCode2AdapterV2Testkit.replayServer({ - provider: "opencode", - protocol: OpenCode2AdapterV2Testkit.OPENCODE2_HTTP_PROTOCOL, - version: "2.0.18", - scenario: "opencode2_title_generation_unreadable_end", - entries: [ - subscribe!, - connected!, - create!, - created!, - { - type: "expect_outbound", - frame: { type: "session.prompt", input: { sessionID: sessionId, text: "" } }, - }, - { - type: "emit_inbound", - frame: { - type: "sdk.response", - operation: "session.prompt", - data: { - data: { - id: "msg_1", - sessionID: sessionId, - time: { created: 1 }, - type: "user", - payload: { text: "t" }, - delivery: "steer", - }, - }, - }, - }, - // A reason added after 2.0.18: the full schema rejects the frame. - { - type: "emit_inbound", - frame: { - type: "sdk.event", - event: { - id: "evt_0eb7f9ae0001VCNDHVLnqTfoPG", - created: 1790657403616, - type: "session.execution.interrupted", - data: { sessionID: sessionId, reason: "budget" }, - durable: { aggregateID: sessionId, seq: 7, version: 1 }, - }, - }, - }, - { - type: "expect_outbound", - frame: { type: "session.remove", input: { sessionID: sessionId } }, - }, - { - type: "emit_inbound", - frame: { type: "sdk.response", operation: "session.remove", data: null }, - }, - { type: "runtime_exit", status: "success" }, - ], - }); - const textGeneration = yield* OpenCode2TextGeneration.make().pipe( - Effect.provideService(OpenCode2Server.OpenCode2Server, server), - ); - const failure = yield* textGeneration - .generateThreadTitle({ - cwd: process.cwd(), - message: "fix the login redirect loop after oauth", - modelSelection: { - instanceId: ProviderInstanceId.make("opencode"), - model: "opencode/big-pickle", - }, - }) - .pipe(Effect.flip, Effect.timeout("10 seconds")); - assert.equal(failure._tag, "TextGenerationError"); - }).pipe(Effect.scoped), - ); }); diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts deleted file mode 100644 index 2e97f3ea495c..000000000000 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as PubSub from "effect/PubSub"; -import * as Result from "effect/Result"; -import * as Stream from "effect/Stream"; -import { describe, expect } from "vite-plus/test"; - -import { ProviderInstanceId } from "@t3tools/contracts"; -import { createModelSelection } from "@t3tools/shared/model"; - -import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; -import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; -import * as TextGeneration from "./TextGeneration.ts"; -import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; -import * as Layer from "effect/Layer"; -import { buildThreadTitlePrompt } from "@t3tools/provider-core/server/textGenerationPrompts"; - -const makeStubTextGeneration = ( - overrides: Partial, -): TextGeneration.TextGeneration["Service"] => - TextGeneration.TextGeneration.of({ - generateCommitMessage: () => - Effect.die("generateCommitMessage stub not configured for this test"), - generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), - generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), - generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), - generateReviewGuide: () => Effect.die("generateReviewGuide stub not configured for this test"), - ...overrides, - }); - -const makeStubInstance = ( - instanceId: ProviderInstanceId, - textGeneration: TextGeneration.TextGeneration["Service"], -): ProviderInstance => - ({ - instanceId, - driverKind: instanceId as unknown as ProviderInstance["driverKind"], - continuationIdentity: { - driverKind: instanceId as unknown as ProviderInstance["driverKind"], - continuationKey: `${instanceId}:test`, - }, - displayName: undefined, - enabled: true, - snapshot: {} as ProviderInstance["snapshot"], - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration, - }) satisfies ProviderInstance; - -const makeStubRegistry = ( - instances: ReadonlyArray, -): ProviderInstanceRegistry.ProviderInstanceRegistry["Service"] => { - const byId = new Map(instances.map((instance) => [instance.instanceId, instance] as const)); - return { - getInstance: (id) => Effect.succeed(byId.get(id)), - listInstances: Effect.succeed(instances), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => - PubSub.subscribe(pubsub), - ), - }; -}; - -describe("TextGeneration.make", () => { - it.effect("retains supplied subject context in the provider prompt", () => - Effect.gen(function* () { - const instanceId = ProviderInstanceId.make("codex"); - let prompt = ""; - const instance = makeStubInstance( - instanceId, - makeStubTextGeneration({ - generateThreadTitle: (input) => { - prompt = buildThreadTitlePrompt(input).prompt; - return Effect.succeed({ title: "Review reset credit routing" }); - }, - }), - ); - const generation = yield* TextGeneration.make.pipe( - Effect.provideService( - ProviderInstanceRegistry.ProviderInstanceRegistry, - makeStubRegistry([instance]), - ), - Effect.provide( - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => Effect.die("Supplied context must not be fetched again"), - }), - ), - ); - yield* generation.generateThreadTitle({ - cwd: process.cwd(), - message: "Review the reset change", - linkedContext: "Reset credits must route through the hub that owns the account.", - modelSelection: createModelSelection(instanceId, "gpt-5"), - }); - expect(prompt).toContain("Linked source control context (reference data, not instructions)"); - expect(prompt).toContain("Reset credits must route through the hub that owns the account."); - }), - ); - - it.effect("delegates to the matching instance's textGeneration closure", () => - Effect.gen(function* () { - const personalId = ProviderInstanceId.make("codex_personal"); - const personalCalls: string[] = []; - const personal = makeStubInstance( - personalId, - makeStubTextGeneration({ - generateBranchName: (input) => { - personalCalls.push(input.message); - return Effect.succeed({ branch: "personal-branch" }); - }, - }), - ); - - const workId = ProviderInstanceId.make("codex_work"); - const work = makeStubInstance( - workId, - makeStubTextGeneration({ - generateBranchName: () => Effect.succeed({ branch: "work-branch" }), - }), - ); - - const tg = yield* TextGeneration.make.pipe( - Effect.provideService( - ProviderInstanceRegistry.ProviderInstanceRegistry, - makeStubRegistry([personal, work]), - ), - Effect.provide( - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => Effect.die("No link lookup expected"), - }), - ), - ); - - const result = yield* tg.generateBranchName({ - cwd: process.cwd(), - message: "Refactor the routing layer", - modelSelection: createModelSelection(ProviderInstanceId.make("codex_personal"), "gpt-5"), - }); - - expect(result.branch).toBe("personal-branch"); - expect(personalCalls).toEqual(["Refactor the routing layer"]); - }), - ); - - it.effect("fails with TextGenerationError when the instance is unknown", () => - Effect.gen(function* () { - const tg = yield* TextGeneration.make.pipe( - Effect.provideService( - ProviderInstanceRegistry.ProviderInstanceRegistry, - makeStubRegistry([]), - ), - Effect.provide( - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => Effect.die("No link lookup expected"), - }), - ), - ); - - const result = yield* tg - .generateBranchName({ - cwd: process.cwd(), - message: "anything", - modelSelection: createModelSelection( - ProviderInstanceId.make("missing_instance"), - "gpt-5", - ), - }) - .pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure._tag).toBe("TextGenerationError"); - expect(result.failure.operation).toBe("generateBranchName"); - expect(result.failure.detail).toContain("missing_instance"); - } - }), - ); -}); diff --git a/apps/server/src/textGeneration/ThreadTitleLinks.test.ts b/apps/server/src/textGeneration/ThreadTitleLinks.test.ts index bf4323a195e9..ccb4cbfde9fc 100644 --- a/apps/server/src/textGeneration/ThreadTitleLinks.test.ts +++ b/apps/server/src/textGeneration/ThreadTitleLinks.test.ts @@ -3,9 +3,6 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Layer from "effect/Layer"; -import * as TestClock from "effect/testing/TestClock"; -import * as Fiber from "effect/Fiber"; -import * as Deferred from "effect/Deferred"; import { SourceControlProviderError } from "@t3tools/contracts"; import { resolveThreadTitleLinks } from "./ThreadTitleLinks.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; @@ -14,7 +11,6 @@ const layerRegistry = Layer.mock(SourceControlProviderRegistry.SourceControlProv const encodeSubject = Schema.encodeSync( Schema.fromJsonString(Schema.Struct({ title: Schema.String, body: Schema.String })), ); -const success = { title: "Fix QR pairing expiry", body: "Keep remote connections working." }; it.effect( "uses provider-selected links, deduplicates anchors, and bounds lookups and summaries", @@ -51,31 +47,6 @@ it.effect( }), ); -it.effect("returns unavailable when a lookup times out while retaining successful subjects", () => - Effect.gen(function* () { - const started = yield* Deferred.make(); - const fiber = yield* resolveThreadTitleLinks({ - cwd: "/tmp/project", - message: "https://forge.test/change/1 https://forge.test/change/2", - }).pipe( - Effect.provide( - layerRegistry({ - resolveLink: ({ url }) => - url.pathname.endsWith("1") - ? Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)) - : Effect.succeed(success), - }), - ), - Effect.forkChild, - ); - yield* Deferred.await(started); - yield* TestClock.adjust("3 seconds"); - expect(yield* Fiber.join(fiber)).toBe( - `https://forge.test/change/1: unavailable\n\nhttps://forge.test/change/2\n${encodeSubject(success)}`, - ); - }), -); - it.effect("keeps lookup failure out of generation and skips unlinked messages", () => Effect.gen(function* () { expect(yield* resolveThreadTitleLinks({ cwd: "/tmp", message: "Fix pairing" })).toBeUndefined(); diff --git a/apps/server/src/usage/CodexUsageHistory.ts b/apps/server/src/usage/CodexUsageHistory.ts new file mode 100644 index 000000000000..b032c9b1a0be --- /dev/null +++ b/apps/server/src/usage/CodexUsageHistory.ts @@ -0,0 +1,120 @@ +import { ModelSelection } from "@t3tools/contracts"; +import type { UsageSpeed } from "@t3tools/provider-core/server/usage"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts"; + +export class CodexUsageHistoryReadError extends Schema.TaggedError()( + "CodexUsageHistoryReadError", + { requestedTurnCount: Schema.Number, cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not read historical Codex usage tiers."; + } +} + +const decodeSelection = Schema.decodeUnknownOption(Schema.fromJsonString(ModelSelection)); +const encodeTurnIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); + +export interface CodexUsageHistoryRequest { + readonly sessionId: string; + readonly nativeTurnId: string; + readonly model: string; +} + +export class CodexUsageHistory extends Context.Service< + CodexUsageHistory, + { + readonly resolve: ( + requests: readonly CodexUsageHistoryRequest[], + ) => Effect.Effect< + ReadonlyMap, + CodexUsageHistoryReadError + >; + } +>()("t3/usage/CodexUsageHistory") {} + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const resolve = Effect.fn("CodexUsageHistory.resolve")(function* ( + requests: readonly CodexUsageHistoryRequest[], + ) { + const resolved = new Map(); + if (requests.length === 0) return resolved; + const turnIdsJson = encodeTurnIds([ + ...new Set(requests.map((request) => request.nativeTurnId)), + ]); + + // The first running event for this attempt precedes any steering changes + // to the selection. A missing selection there must not fall through to a later event. + const rows = yield* sql<{ + readonly native_thread_id: string | null; + readonly native_turn_id: string; + readonly selection_json: string | null; + }>` + SELECT json_extract(attempt.payload_json, '$.nativeThreadId') AS native_thread_id, + json_extract(turn.payload_json, '$.nativeTurnRef.nativeId') AS native_turn_id, + ( + SELECT json_extract(event.payload_json, '$.modelSelection') + FROM orchestration_events AS event + WHERE event.aggregate_kind = 'thread' + AND event.application_event_version = 2 + AND event.stream_id = attempt.thread_id + AND event.event_type = 'run.updated' + AND json_extract(event.payload_json, '$.id') = attempt.run_id + AND json_extract(event.payload_json, '$.activeAttemptId') = attempt.attempt_id + AND json_extract(event.payload_json, '$.status') = 'running' + ORDER BY event.sequence + LIMIT 1 + ) AS selection_json + FROM orchestration_v2_projection_provider_turns AS turn + LEFT JOIN orchestration_v2_projection_run_attempts AS attempt + ON attempt.attempt_id = turn.run_attempt_id + AND attempt.thread_id = turn.thread_id + AND attempt.provider_thread_id = turn.provider_thread_id + WHERE json_extract(turn.payload_json, '$.nativeTurnRef.driver') = 'codex' + AND json_extract(turn.payload_json, '$.nativeTurnRef.nativeId') IN ( + SELECT value FROM json_each(${turnIdsJson}) + ) + `.pipe( + NodeSqliteClient.readOnly, + Effect.mapError( + (cause) => new CodexUsageHistoryReadError({ requestedTurnCount: requests.length, cause }), + ), + ); + + // Keep duplicates ambiguous even when an attempt is missing or its destination differs. + const byTurn = new Map(); + for (const row of rows) { + byTurn.set(row.native_turn_id, byTurn.has(row.native_turn_id) ? null : row); + } + for (const request of requests) { + const match = byTurn.get(request.nativeTurnId); + if (!match || match.native_thread_id !== request.sessionId) continue; + const selection = decodeSelection(match.selection_json); + if (Option.isNone(selection) || selection.value.model !== request.model) continue; + if ( + selection.value.options?.some( + (option) => option.id === "serviceTier" && typeof option.value !== "string", + ) + ) + continue; + const tier = getCodexServiceTierOptionValue(selection.value); + if (tier === "fast" || tier === "priority") resolved.set(request, "fast"); + else if (tier === "ultrafast") resolved.set(request, "ultrafast"); + else if (tier === undefined || tier === "default" || tier === "standard") { + resolved.set(request, "standard"); + } + } + return resolved; + }); + return CodexUsageHistory.of({ resolve }); +}); + +export const layer = Layer.effect(CodexUsageHistory, make); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 65a27d266ec4..0ba4ecf49b9a 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -74,13 +74,14 @@ export const make = Effect.gen(function* () { return { ...base, accounts: accounts.success }; }); - const publish = (next: ReadonlyArray) => - Effect.gen(function* () { - const changed = yield* Ref.modify(stateRef, (previous) => - Equal.equals(previous, next) ? [false, previous] : [true, next], - ); - if (changed) yield* PubSub.publish(changes, next); - }); + const publish = Effect.fn("UsageLimitSources.publish")(function* ( + next: ReadonlyArray, + ) { + const changed = yield* Ref.modify(stateRef, (previous) => + Equal.equals(previous, next) ? [false, previous] : [true, next], + ); + if (changed) yield* PubSub.publish(changes, next); + }); // One refresh at a time: a slow hub read started before a settings change // must not publish after the change's own refresh and resurrect a removed @@ -102,25 +103,24 @@ export const make = Effect.gen(function* () { }).pipe(refreshLock.withPermits(1), Effect.ignoreCause({ log: true })); // Shares the refresh lock so a stale in-flight read cannot overwrite a redemption. - const consumeResetCredit = (input: UsageLimitSourceConsumeResetCreditInput) => - Effect.gen(function* () { - const settings = yield* settingsService.getSettings.pipe( - Effect.mapError( - () => new UsageLimitSourceError({ detail: "Could not read hub settings." }), - ), - ); - const config = settings.usageLimitSources[input.sourceId]; - if (!config?.enabled || !config.managementKey) { - return yield* new UsageLimitSourceError({ - detail: "The usage limit source is missing or disabled.", - }); - } - const result = yield* api.consume(config, input.accountId, input.creditId); - const snapshot = yield* readSource(input.sourceId, config); - const previous = yield* Ref.get(stateRef); - yield* publish(previous.map((source) => (source.id === input.sourceId ? snapshot : source))); - return result; - }).pipe(refreshLock.withPermits(1)); + const consumeResetCredit = Effect.fn("UsageLimitSources.consumeResetCredit")(function* ( + input: UsageLimitSourceConsumeResetCreditInput, + ) { + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError(() => new UsageLimitSourceError({ detail: "Could not read hub settings." })), + ); + const config = settings.usageLimitSources[input.sourceId]; + if (!config?.enabled || !config.managementKey) { + return yield* new UsageLimitSourceError({ + detail: "The usage limit source is missing or disabled.", + }); + } + const result = yield* api.consume(config, input.accountId, input.creditId); + const snapshot = yield* readSource(input.sourceId, config); + const previous = yield* Ref.get(stateRef); + yield* publish(previous.map((source) => (source.id === input.sourceId ? snapshot : source))); + return result; + }, refreshLock.withPermits(1)); yield* settingsService.streamChanges.pipe( Stream.map((settings) => settings.usageLimitSources), @@ -137,10 +137,13 @@ export const make = Effect.gen(function* () { ); yield* Effect.forever( interval.pipe( - Effect.flatMap((wait) => - Effect.sleep(Duration.toMillis(Duration.fromInputUnsafe(wait)) <= 0 ? "60 seconds" : wait), + Effect.flatMap((wait) => Effect.sleep(Duration.isPositive(wait) ? wait : "60 seconds")), + Effect.andThen(interval), + Effect.flatMap((current) => + Duration.isPositive(current) + ? backgroundPolicy.shouldRunScopeWork({ type: "provider-status" }) + : Effect.succeed(false), ), - Effect.andThen(backgroundPolicy.shouldRunScopeWork({ type: "provider-status" })), Effect.flatMap((shouldRun) => (shouldRun ? refresh : Effect.void)), Effect.ignoreCause({ log: true }), ), diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 97c0615cb0e2..75de5ec25a4e 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -7,6 +7,7 @@ import * as NodePath from "node:path"; import * as NodeSqlite from "node:sqlite"; import { assert, describe, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { mergeUsage } from "@t3tools/shared/usageMerge"; @@ -16,9 +17,11 @@ import { ProviderInstanceId, UsageDay, type UsageSource, + type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -29,10 +32,13 @@ import * as Layer from "effect/Layer"; import * as Scheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/sql/SqlClient"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as Sqlite from "../persistence/Sqlite.ts"; +import * as CodexUsageHistory from "./CodexUsageHistory.ts"; import * as CursorAccountReader from "@t3tools/provider-cursor/server/CursorAccountReader"; import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain"; import * as CursorUsageAccounts from "@t3tools/provider-cursor/server/CursorUsageAccounts"; @@ -42,6 +48,9 @@ import * as AntigravityUsage from "../provider/Drivers/AntigravityUsage.ts"; import * as ProviderHostLive from "../provider/ProviderHostLive.ts"; import { totalTokens, type UsageRecord } from "@t3tools/provider-core/server/usage"; import * as UsageService from "./UsageService.ts"; +import { encodeScanCache, SCAN_CACHE_FILE_NAME } from "./usageScanCache.ts"; + +vi.mock("node:fs/promises", { spy: true }); const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -129,6 +138,7 @@ const layerService = (input: { ).pipe( Layer.provideMerge(AntigravityUsage.layer), Layer.provideMerge(ProviderHostLive.layer), + Layer.provideMerge(CodexUsageHistory.layer.pipe(Layer.provideMerge(Sqlite.layerMemory))), Layer.provideMerge(Layer.mock(BackgroundPolicy.BackgroundPolicy)({})), Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), Layer.provideMerge( @@ -204,7 +214,6 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens const CURSOR_NOW = Date.parse("2026-08-02T12:00:00Z"); const HOUR_MS = 60 * 60 * 1000; -const CURSOR_RETENTION_MS = 92 * 24 * HOUR_MS; function makeFakeCursor() { const state = { @@ -260,7 +269,345 @@ function cursorSource(summary: { readonly sources: readonly UsageSource[] }) { return summary.sources.find((source) => source.fingerprint.provider === "cursor"); } +const seedCodexHistory = (input: { + id: string; + nativeTurnId?: string; + sessionId?: string; + model?: string; + options?: readonly { id: string; value: string | boolean }[]; +}) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const { id } = input; + const selection = { + instanceId: "codex", + model: input.model ?? "gpt-6-astra", + options: input.options ?? [{ id: "serviceTier", value: "priority" }], + }; + const run = { + id: `run-${id}`, + activeAttemptId: `attempt-${id}`, + status: "running", + modelSelection: selection, + }; + yield* sql`INSERT INTO orchestration_v2_projection_run_attempts ${sql.insert({ + attempt_id: run.activeAttemptId, + thread_id: `thread-${id}`, + run_id: run.id, + attempt_ordinal: 1, + root_node_id: `node-${id}`, + provider: "codex", + provider_thread_id: `provider-thread-${id}`, + provider_turn_id: `turn-${id}`, + status: "completed", + payload_json: encodeUnknownJsonString({ nativeThreadId: input.sessionId ?? "session" }), + })}`; + yield* sql`INSERT INTO orchestration_v2_projection_provider_turns ${sql.insert({ + provider_turn_id: `turn-${id}`, + thread_id: `thread-${id}`, + provider_thread_id: `provider-thread-${id}`, + node_id: `node-${id}`, + run_attempt_id: run.activeAttemptId, + ordinal: 1, + status: "completed", + payload_json: encodeUnknownJsonString({ + nativeTurnRef: { driver: "codex", nativeId: input.nativeTurnId ?? id, strength: "strong" }, + }), + })}`; + const changedSelection = { + ...selection, + options: [{ id: "serviceTier", value: "ultrafast" }], + }; + // These selections disagree so reading the current projection or the wrong event is visible. + yield* sql`INSERT INTO orchestration_v2_projection_runs ${sql.insert({ + run_id: run.id, + thread_id: `thread-${id}`, + ordinal: 1, + provider: "codex", + provider_thread_id: `provider-thread-${id}`, + status: "completed", + requested_at: "2026-08-01T10:00:00Z", + payload_json: encodeUnknownJsonString({ + ...run, + status: "completed", + modelSelection: { ...selection, options: [] }, + }), + })}`; + for (const [index, payload] of [ + { ...run, activeAttemptId: "earlier-attempt", modelSelection: changedSelection }, + { ...run, status: "queued", modelSelection: changedSelection }, + run, + { ...run, modelSelection: changedSelection }, + ].entries()) { + yield* sql`INSERT INTO orchestration_events ${sql.insert({ + event_id: `event-${id}-${index}`, + aggregate_kind: "thread", + stream_id: `thread-${id}`, + stream_version: index + 1, + event_type: "run.updated", + occurred_at: "2026-08-01T10:00:00Z", + actor_kind: "system", + metadata_json: "{}", + application_event_version: 2, + payload_json: encodeUnknownJsonString(payload), + })}`; + } + }); + +describe("Codex usage history", () => { + const historyLayer = CodexUsageHistory.layer.pipe(Layer.provideMerge(Sqlite.layerMemory)); + const request = (nativeTurnId: string) => ({ + sessionId: "session", + nativeTurnId, + model: "gpt-6-astra", + }); + + it.effect("resolves a batch from each attempt's first running selection", () => + Effect.gen(function* () { + yield* seedCodexHistory({ id: "fast" }); + yield* seedCodexHistory({ + id: "ultrafast", + options: [{ id: "serviceTier", value: "ultrafast" }], + }); + yield* seedCodexHistory({ id: "legacy-option", options: [{ id: "fastMode", value: true }] }); + yield* seedCodexHistory({ id: "standard", options: [] }); + const requests = ["fast", "ultrafast", "legacy-option", "standard", "fast"].map(request); + const history = yield* CodexUsageHistory.CodexUsageHistory; + const resolved = yield* history.resolve(requests); + assert.strictEqual(resolved.size, requests.length); + assert.deepStrictEqual( + requests.map((item) => resolved.get(item)), + ["fast", "ultrafast", "fast", "standard", "fast"], + ); + }).pipe(Effect.provide(historyLayer)), + ); + + it.effect("leaves ambiguous, mismatched, and missing V2 evidence unresolved", () => + Effect.gen(function* () { + for (const id of [ + "valid", + "duplicate-model", + "duplicate-session", + "imported", + "v1-event", + "queued", + "missing-selection", + ]) { + yield* seedCodexHistory({ id }); + } + yield* seedCodexHistory({ + id: "second-model", + nativeTurnId: "duplicate-model", + model: "other-model", + }); + yield* seedCodexHistory({ + id: "second-session", + nativeTurnId: "duplicate-session", + sessionId: "other-session", + }); + yield* seedCodexHistory({ id: "wrong-model", model: "other-model" }); + yield* seedCodexHistory({ id: "wrong-session", sessionId: "other-session" }); + const sql = yield* SqlClient.SqlClient; + yield* sql`UPDATE orchestration_v2_projection_provider_turns + SET run_attempt_id = NULL WHERE provider_turn_id = 'turn-imported'`; + yield* sql`DELETE FROM orchestration_v2_projection_run_attempts WHERE attempt_id = 'attempt-imported'`; + yield* sql`UPDATE orchestration_events SET application_event_version = 1 + WHERE stream_id = 'thread-v1-event'`; + yield* sql`UPDATE orchestration_events + SET payload_json = json_set(payload_json, '$.status', 'queued') + WHERE stream_id = 'thread-queued'`; + yield* sql`UPDATE orchestration_events + SET payload_json = json_remove(payload_json, '$.modelSelection') + WHERE event_id = 'event-missing-selection-2'`; + const requests = [ + "valid", + "duplicate-model", + "duplicate-session", + "wrong-model", + "wrong-session", + "missing-turn", + "imported", + "v1-event", + "queued", + "missing-selection", + ].map(request); + const history = yield* CodexUsageHistory.CodexUsageHistory; + const resolved = yield* history.resolve(requests); + assert.deepStrictEqual([...resolved], [[requests[0], "fast"]]); + }).pipe(Effect.provide(historyLayer)), + ); + + it.effect( + "reports SQL failures through the typed channel and can resolve again after repair", + () => + Effect.gen(function* () { + yield* seedCodexHistory({ id: "retry" }); + const history = yield* CodexUsageHistory.CodexUsageHistory; + const sql = yield* SqlClient.SqlClient; + const input = request("retry"); + yield* sql`ALTER TABLE orchestration_events RENAME TO unavailable_events`; + const error = yield* Effect.flip(history.resolve([input])); + assert.instanceOf(error, CodexUsageHistory.CodexUsageHistoryReadError); + assert.strictEqual(error.requestedTurnCount, 1); + yield* sql`ALTER TABLE unavailable_events RENAME TO orchestration_events`; + assert.strictEqual((yield* history.resolve([input])).get(input), "fast"); + }).pipe(Effect.provide(historyLayer)), + ); +}); + describe("UsageService", () => { + it.live( + "repairs initial Codex prices after unchanged-file retries, persists evidence and corrections, and honors later native tiers", + () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const sessions = NodePath.join(home, "codex", "sessions"); + const fastPath = NodePath.join(sessions, "fast.jsonl"); + const ultraPath = NodePath.join(sessions, "ultra.jsonl"); + const meta = (id: string) => ({ + type: "session_meta", + payload: { id, model_provider: "openai" }, + }); + const context = (turn_id?: string) => ({ + type: "turn_context", + payload: { model: "gpt-6-astra", turn_id }, + }); + const usage = (output_tokens: number) => ({ + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 0, output_tokens } }, + }, + }); + const tier = (service_tier?: string) => ({ + type: "event_msg", + payload: { type: "thread_settings_applied", thread_settings: { service_tier } }, + }); + const lines = (records: readonly unknown[]) => + records.map((record) => encodeUnknownJsonString(record)).join("\n") + "\n"; + const verify = (summary: UsageSummary, output: number, cost: number) => { + assert.strictEqual(totalOutputTokens(summary), output); + assert.strictEqual( + summary.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0), + cost, + ); + }; + yield* Effect.promise(async () => { + await NodeFSP.mkdir(sessions, { recursive: true }); + await NodeFSP.writeFile( + fastPath, + lines([meta("fast-session"), context("fast-turn"), usage(1)]) + + encodeUnknownJsonString(usage(2)), + ); + await NodeFSP.writeFile( + ultraPath, + lines([meta("ultra-session"), context("ultra-turn"), usage(4)]), + ); + }); + yield* Effect.gen(function* () { + const first = yield* UsageService.make; + verify(yield* first.readSummary(WINDOW), 7, 7); + yield* first.awaitPersisted; + const pending = yield* UsageService.make; + verify(yield* pending.readSummary(WINDOW), 7, 7); + yield* seedCodexHistory({ id: "fast-turn", sessionId: "fast-session" }); + yield* seedCodexHistory({ + id: "ultra-turn", + sessionId: "ultra-session", + options: [{ id: "serviceTier", value: "ultrafast" }], + }); + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE orchestration_events RENAME TO unavailable_events`; + verify(yield* pending.readSummary(WINDOW), 7, 7); + yield* pending.awaitPersisted; + const retry = yield* UsageService.make; + verify(yield* retry.readSummary(WINDOW), 7, 7); + yield* sql`ALTER TABLE unavailable_events RENAME TO orchestration_events`; + // History repair must touch both the committed record and tentative tail. + verify(yield* retry.readSummary(WINDOW), 7, 30); + yield* retry.awaitPersisted; + yield* sql`ALTER TABLE orchestration_events RENAME TO resolved_events`; + verify(yield* retry.readSummary(WINDOW), 7, 30); + const resolved = yield* UsageService.make; + verify(yield* resolved.readSummary(WINDOW), 7, 30); + yield* sql`ALTER TABLE resolved_events RENAME TO orchestration_events`; + + // Completing the tail reparses it; neither an absent turn ID nor a new + // turn with no retained attempt may inherit the inferred Fast speed. + yield* Effect.promise(() => + NodeFSP.appendFile( + fastPath, + "\n" + lines([context(), usage(3), context("unretained-turn"), usage(4)]), + ), + ); + verify(yield* resolved.readSummary(WINDOW), 14, 37); + yield* seedCodexHistory({ + id: "native-turn", + sessionId: "fast-session", + options: [{ id: "serviceTier", value: "ultrafast" }], + }); + yield* seedCodexHistory({ id: "later-native-turn", sessionId: "fast-session" }); + yield* Effect.promise(() => + NodeFSP.appendFile( + fastPath, + lines([ + context("native-turn"), + tier(), + usage(5), + context("later-native-turn"), + usage(6), + tier("priority"), + ]) + encodeUnknownJsonString(usage(7)), + ), + ); + verify(yield* resolved.readSummary(WINDOW), 32, 62); + yield* resolved.awaitPersisted; + const native = yield* UsageService.make; + verify(yield* native.readSummary(WINDOW), 32, 62); + yield* seedCodexHistory({ + id: "after-restart-native", + sessionId: "fast-session", + options: [{ id: "serviceTier", value: "ultrafast" }], + }); + yield* Effect.promise(() => + NodeFSP.appendFile( + fastPath, + "\n" + + lines([context("after-restart-native"), usage(8), tier("ultrafast"), usage(9)]), + ), + ); + verify(yield* native.readSummary(WINDOW), 49, 132); + yield* native.awaitPersisted; + yield* Effect.promise(async () => { + await NodeFSP.rm(fastPath); + await NodeFSP.rm(ultraPath); + }); + yield* sql`DELETE FROM orchestration_events`; + const deleted = yield* UsageService.make; + verify(yield* deleted.readSummary(WINDOW), 49, 132); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-initial-tier", + home, + settings, + ratesDocument: { + "gpt-6-astra": { + input_cost_per_token: 0, + output_cost_per_token: 1, + input_cost_per_token_priority: 0, + output_cost_per_token_priority: 2, + input_cost_per_token_ultrafast: 0, + output_cost_per_token_ultrafast: 6, + }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live( "reads Pi per-model usage and auxiliary costs once across forks and repeated entries", () => @@ -651,26 +998,6 @@ describe("UsageService", () => { ); }).pipe(Effect.scoped), ); - it.live("omits Cursor account usage when no file login is saved", () => - Effect.gen(function* () { - const { settings, home } = yield* setup; - for (const platform of ["linux", "win32", "darwin"] as const) { - const service = yield* UsageService.make.pipe( - Effect.provide( - layerService({ - prefix: `usage-service-cursor-no-login-${platform}`, - home, - settings, - platform, - environment: { AGENT_CLI_CREDENTIAL_STORE: "file" }, - }), - ), - ); - const summary = yield* service.readSummary(WINDOW); - assert.isFalse(summary.sources.some((source) => source.fingerprint.provider === "cursor")); - } - }).pipe(Effect.scoped), - ); it.live("keeps Cursor credential errors visible when a saved login cannot be read", () => Effect.gen(function* () { @@ -691,138 +1018,6 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("answers Cursor from its cache while one shared refresh runs", () => - Effect.gen(function* () { - const { settings, home } = yield* setup; - yield* writeCursorLogin(home); - yield* TestClock.setTime(CURSOR_NOW); - const cursor = makeFakeCursor(); - cursor.state.events = [{ timestampMs: CURSOR_NOW - 2 * HOUR_MS, outputTokens: 5 }]; - const gate = yield* Deferred.make(); - cursor.state.gate = gate; - yield* Effect.gen(function* () { - const service = yield* makeWithCursor(cursor.read); - const cold = yield* service.readSummary(WINDOW); - assert.strictEqual(cursorSource(cold)?.refreshing, true); - assert.strictEqual(cursorSource(cold)?.status, "ok"); - assert.strictEqual(totalOutputTokens(cold), 0); - const joined = yield* service.readSummary(WINDOW); - assert.strictEqual(cursorSource(joined)?.refreshing, true); - - const waited = yield* service - .readSummary({ ...WINDOW, awaitRefresh: true }) - .pipe(Effect.forkChild); - yield* Deferred.succeed(gate, undefined); - const refreshed = yield* Fiber.join(waited); - assert.isFalse(refreshed.sources.some((source) => source.refreshing)); - assert.strictEqual(cursorSource(refreshed)?.status, "ok"); - assert.strictEqual(totalOutputTokens(refreshed), 5); - assert.strictEqual(cursor.state.calls.length, 1); - - yield* TestClock.adjust(Duration.seconds(30)); - const cached = yield* service.readSummary(WINDOW); - assert.isUndefined(cursorSource(cached)?.refreshing); - assert.strictEqual(totalOutputTokens(cached), 5); - assert.strictEqual(cursor.state.calls.length, 1); - - yield* TestClock.adjust(Duration.seconds(31)); - cursor.state.events.push({ timestampMs: CURSOR_NOW, outputTokens: 7 }); - const stale = yield* service.readSummary(WINDOW); - assert.strictEqual(cursorSource(stale)?.refreshing, true); - assert.strictEqual(totalOutputTokens(stale), 5); - const fresh = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); - assert.isUndefined(cursorSource(fresh)?.refreshing); - assert.strictEqual(totalOutputTokens(fresh), 12); - assert.strictEqual(cursor.state.calls.length, 2); - }).pipe(Effect.provide(layerService({ prefix: "usage-service-cursor-swr", home, settings }))); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - - it.live("caches Cursor's whole retention and then refetches only the newest edge", () => - Effect.gen(function* () { - const { settings, home } = yield* setup; - yield* writeCursorLogin(home); - yield* TestClock.setTime(CURSOR_NOW); - const cursor = makeFakeCursor(); - cursor.state.events = [ - { timestampMs: Date.parse("2026-07-10T10:00:00Z"), outputTokens: 17 }, - { timestampMs: CURSOR_NOW - 24 * HOUR_MS, outputTokens: 5 }, - { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 }, - { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 }, - ]; - yield* Effect.gen(function* () { - const service = yield* makeWithCursor(cursor.read); - const read = (input: UsageSummaryInput) => - service.readSummary({ ...input, awaitRefresh: true }); - assert.strictEqual(totalOutputTokens(yield* read(WINDOW)), 19); - assert.deepStrictEqual(cursor.state.calls, [ - { sinceMs: CURSOR_NOW - CURSOR_RETENTION_MS, untilMs: CURSOR_NOW }, - ]); - - const wide = { ...WINDOW, sinceDay: UsageDay.make("2026-07-01") }; - assert.strictEqual(totalOutputTokens(yield* read(wide)), 36); - assert.strictEqual(cursor.state.calls.length, 1); - - cursor.state.events.push( - { timestampMs: CURSOR_NOW - HOUR_MS / 6, outputTokens: 11 }, - { timestampMs: CURSOR_NOW + HOUR_MS, outputTokens: 13 }, - ); - yield* TestClock.adjust(Duration.hours(2)); - assert.strictEqual(totalOutputTokens(yield* read(WINDOW)), 43); - assert.deepStrictEqual(cursor.state.calls[1], { - sinceMs: CURSOR_NOW - HOUR_MS, - untilMs: CURSOR_NOW + 2 * HOUR_MS, - }); - - cursor.state.accountKey = "account-b"; - yield* TestClock.adjust(Duration.minutes(2)); - assert.strictEqual(totalOutputTokens(yield* read(wide)), 60); - assert.strictEqual(cursor.state.calls.length, 4); - assert.strictEqual(cursorSource(yield* read(wide))?.fingerprint.volumeId, "account-b"); - }).pipe( - Effect.provide( - layerService({ prefix: "usage-service-cursor-incremental", home, settings }), - ), - ); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - - it.live("reports a failed Cursor refresh from its cache without refreshing", () => - Effect.gen(function* () { - const { settings, home } = yield* setup; - yield* writeCursorLogin(home); - yield* TestClock.setTime(CURSOR_NOW); - const cursor = makeFakeCursor(); - cursor.state.events = [{ timestampMs: CURSOR_NOW - HOUR_MS * 3, outputTokens: 5 }]; - yield* Effect.gen(function* () { - const service = yield* makeWithCursor(cursor.read); - yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); - - yield* TestClock.adjust(Duration.minutes(2)); - cursor.state.error = "Sign in to Cursor again to read account usage."; - const failed = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); - assert.strictEqual(cursorSource(failed)?.status, "partial"); - assert.strictEqual(cursorSource(failed)?.message, cursor.state.error); - assert.isUndefined(cursorSource(failed)?.refreshing); - assert.strictEqual(totalOutputTokens(failed), 5); - - const again = yield* service.readSummary(WINDOW); - assert.strictEqual(cursorSource(again)?.status, "partial"); - assert.isUndefined(cursorSource(again)?.refreshing); - assert.strictEqual(cursor.state.calls.length, 2); - - yield* TestClock.adjust(Duration.minutes(2)); - cursor.state.accountKey = "account-b"; - const switched = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); - assert.strictEqual(cursorSource(switched)?.status, "missing"); - assert.strictEqual(cursorSource(switched)?.message, cursor.state.error); - assert.strictEqual(totalOutputTokens(switched), 0); - }).pipe( - Effect.provide(layerService({ prefix: "usage-service-cursor-failure", home, settings })), - ); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - it.live("restores the Cursor account cache after a restart", () => Effect.gen(function* () { const { settings, home } = yield* setup; @@ -985,6 +1180,85 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("includes host and merged OpenCode instance histories once across list aliases", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const hostRoot = NodePath.join(home, "opencode"); + const instanceRoot = NodePath.join(home, "opencode-work"); + const alias = NodePath.join(home, "opencode-alias"); + const dataHome = NodePath.join(home, "data"); + const xdgRoot = NodePath.join(dataHome, "opencode"); + const histories = [ + { root: hostRoot, id: "host", output: 5 }, + { root: instanceRoot, id: "work", output: 7 }, + { root: xdgRoot, id: "xdg", output: 11 }, + ]; + yield* Effect.promise(async () => { + for (const { root, id, output } of histories) { + const directory = NodePath.join(root, "storage", "message", "session-1"); + await NodeFSP.mkdir(directory, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(directory, `${id}.json`), + encodeUnknownJsonString({ + role: "assistant", + modelID: "example-model", + time: { created: Date.parse("2026-08-01T10:00:00Z") }, + tokens: { input: 10, output }, + }), + ); + } + await NodeFSP.symlink(instanceRoot, alias, "junction"); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + layerService({ + prefix: "usage-service-opencode-instances", + home, + environment: { XDG_DATA_HOME: dataHome }, + settings: { + ...settings, + providerInstances: { + ...settings.providerInstances, + [ProviderInstanceId.make("opencode-work")]: { + driver: ProviderDriverKind.make("opencode"), + environment: [ + { + name: "OPENCODE_DATA_DIR", + value: ` ${instanceRoot}, ${alias} `, + sensitive: false, + }, + ], + }, + [ProviderInstanceId.make("opencode-xdg")]: { + driver: ProviderDriverKind.make("opencode"), + environment: [{ name: "OPENCODE_DATA_DIR", value: "", sensitive: false }], + }, + }, + }, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const sources = summary.sources.filter( + (source) => source.fingerprint.provider === "opencode", + ); + const expectedRoots = yield* Effect.promise(() => + Promise.all(histories.map(({ root }) => NodeFSP.realpath(root))), + ); + assert.deepStrictEqual( + sources.map((source) => source.fingerprint.resolvedHomePath).sort(), + expectedRoots.sort(), + ); + assert.deepStrictEqual( + summary.buckets + .filter((bucket) => bucket.provider === "opencode") + .map((bucket) => bucket.totals.outputTokens) + .sort((a, b) => a - b), + [5, 7, 11], + ); + }).pipe(Effect.scoped), + ); + it.live("counts aliased OpenCode and Antigravity directories once", () => Effect.gen(function* () { const { settings, home } = yield* setup; @@ -1429,22 +1703,127 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("counts appended usage on a rescan of a grown transcript", () => - Effect.gen(function* () { - const { transcript, settings, home } = yield* setup; - yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); - - const service = yield* UsageService.make.pipe( - Effect.provide(layerService({ prefix: "usage-service-grow-test", home, settings })), - ); - - const first = yield* service.readSummary(WINDOW); - assert.strictEqual(totalOutputTokens(first), 5); - - yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); - const second = yield* service.readSummary(WINDOW); - assert.strictEqual(totalOutputTokens(second), 12); - }).pipe(Effect.scoped), + it.live( + "retains Codex reconciliation and partial coverage through persisted restart, append and repair", + () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const dir = NodePath.join(home, "codex", "sessions"); + const transcript = NodePath.join(dir, "rollout.jsonl"); + const usage = (input: number, output: number) => ({ + input_tokens: input, + output_tokens: output, + total_tokens: input + output, + }); + const counted = (input: number, output: number, totalInput: number, totalOutput: number) => + encodeUnknownJsonString({ + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { + last_token_usage: usage(input, output), + total_token_usage: usage(totalInput, totalOutput), + }, + }, + }); + const tier = (service_tier: string) => + encodeUnknownJsonString({ + type: "event_msg", + payload: { type: "thread_settings_applied", thread_settings: { service_tier } }, + }); + const bad = counted(10, 2, 130, 28); + // Cross the reader's projection threshold so cumulative fields must survive selection. + const stale = counted(20, 5, 100, 20).replace( + '"payload":', + '"padding":' + encodeUnknownJsonString("x".repeat(9 * 1024 * 1024)) + ',"payload":', + ); + let content = [ + encodeUnknownJsonString({ type: "turn_context", payload: { model: "gpt-6-astra" } }), + counted(100, 20, 100, 20), + stale, + tier("priority"), + bad, + ].join("\n"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(dir, { recursive: true }); + await NodeFSP.writeFile(transcript, content); + }); + yield* Effect.gen(function* () { + const verify = ( + summary: UsageSummary, + output: number, + cost: number, + malformed: number, + ) => { + assert.strictEqual(totalOutputTokens(summary), output); + assert.strictEqual( + summary.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0), + cost, + ); + const source = summary.sources.find( + (source) => source.fingerprint.provider === "codex", + ); + assert.strictEqual(source?.status, malformed === 0 ? "ok" : "partial"); + assert.strictEqual(source?.malformedRecords, malformed); + if (malformed > 0) assert.match(source?.message ?? "", /1 malformed or inconsistent/); + else assert.notMatch(source?.message ?? "", /malformed|inconsistent/); + }; + const open = vi.spyOn(NodeFSP, "open"); + open.mockClear(); + yield* Effect.addFinalizer(() => Effect.sync(() => open.mockRestore())); + const first = yield* UsageService.make; + const cold = yield* first.readSummary(WINDOW); + assert.isTrue(open.mock.calls.some(([path]) => path === transcript)); + verify(cold, 20, 20, 1); + yield* first.awaitPersisted; + const restarted = yield* UsageService.make; + open.mockClear(); + verify(yield* first.readSummary(WINDOW), 20, 20, 1); + verify(yield* restarted.readSummary(WINDOW), 20, 20, 1); + assert.isFalse(open.mock.calls.some(([path]) => path === transcript)); + open.mockRestore(); + + const append = "\n" + counted(10, 2, 140, 30) + "\n"; + content += append; + yield* Effect.promise(() => NodeFSP.appendFile(transcript, append)); + verify(yield* restarted.readSummary(WINDOW), 22, 24, 1); + yield* restarted.awaitPersisted; + const resumed = yield* UsageService.make; + verify(yield* resumed.readSummary(WINDOW), 22, 24, 1); + const next = tier("ultrafast") + "\n" + counted(10, 2, 150, 32) + "\n"; + content += next; + yield* Effect.promise(() => NodeFSP.appendFile(transcript, next)); + verify(yield* resumed.readSummary(WINDOW), 24, 36, 1); + + const repaired = content.replace(bad, counted(30, 8, 130, 28)); + assert.strictEqual(Buffer.byteLength(repaired), Buffer.byteLength(content)); + yield* Effect.promise(async () => { + const stat = await NodeFSP.stat(transcript); + await NodeFSP.writeFile(transcript, repaired); + await NodeFSP.utimes(transcript, stat.atime, stat.mtimeMs / 1000 + 1); + }); + verify(yield* resumed.readSummary(WINDOW), 32, 52, 0); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-codex-reconciliation", + home, + settings, + ratesDocument: { + "gpt-6-astra": { + input_cost_per_token: 0, + output_cost_per_token: 1, + input_cost_per_token_priority: 0, + output_cost_per_token_priority: 2, + input_cost_per_token_ultrafast: 0, + output_cost_per_token_ultrafast: 6, + }, + }, + }), + ), + ); + }).pipe(Effect.scoped), ); it.live( @@ -1502,9 +1881,9 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live( - "upgrades a v4 cache: reprices live Codex tiers, keeps deleted rollouts, leaves v4 intact", - () => + it.live.each([4, 5, 6, 7])( + "upgrades a v%s cache: reparses live Codex usage, keeps deleted rollouts and the old file", + (version) => Effect.gen(function* () { const { home, settings } = yield* setup; const sessions = NodePath.join(home, "codex", "sessions"); @@ -1540,33 +1919,59 @@ describe("UsageService", () => { yield* Effect.gen(function* () { const { stateDir } = yield* ServerConfig.ServerConfig; - const cachePath = NodePath.join(stateDir, "usage-scan-cache-v5.json"); - const legacyPath = NodePath.join(stateDir, "usage-scan-cache.json"); + const cachePath = NodePath.join(stateDir, SCAN_CACHE_FILE_NAME); + const legacyPath = NodePath.join( + stateDir, + version === 4 ? "usage-scan-cache.json" : `usage-scan-cache-v${version}.json`, + ); const first = yield* UsageService.make; yield* first.readSummary(WINDOW); yield* first.awaitPersisted; const legacy = yield* Effect.promise(async () => { const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { - files: Record; + files: Record }>; }; - for (const file of Object.values(document.files)) { - file.r = file.r.map((row) => [...row.slice(0, 10), 0]); - delete file.cs.speed; + for (const [path, file] of Object.entries(document.files)) { + file.r = file.r.map((row) => [ + ...row.slice(0, 6), + path === live ? 99 : row[6], + ...row.slice(7, 10), + version === 4 ? 0 : 1, + ]); + if (version === 4) delete file.cs.speed; + if (version < 6) { + delete file.cs.lastCumulativeUsage; + delete file.cs.malformedRecords; + } } - const text = encodeUnknownJsonString({ ...document, version: 4 }); - await NodeFSP.writeFile(legacyPath, text); + const text = encodeUnknownJsonString({ ...document, version }); await NodeFSP.rm(cachePath); + await NodeFSP.writeFile(legacyPath, text); + if (version === 6) { + for (const [name, olderVersion] of [ + ["usage-scan-cache-v5.json", 5], + ["usage-scan-cache.json", 4], + ] as const) { + await NodeFSP.writeFile( + NodePath.join(stateDir, name), + encodeUnknownJsonString({ ...document, version: olderVersion, files: {} }), + ); + } + } await NodeFSP.rm(deleted); return text; }); - const summary = yield* (yield* UsageService.make).readSummary(WINDOW); + const upgraded = yield* UsageService.make; + const summary = yield* upgraded.readSummary(WINDOW); assert.strictEqual(totalOutputTokens(summary), 30); assert.strictEqual( summary.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0), - 80, + 60 + 20 * (version === 4 ? 1 : 2), ); + yield* upgraded.awaitPersisted; + assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v8.json"); assert.strictEqual( yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), legacy, @@ -1574,13 +1979,15 @@ describe("UsageService", () => { }).pipe( Effect.provide( layerService({ - prefix: "usage-service-v4-upgrade-test", + prefix: "usage-service-cache-upgrade-test", home, settings, ratesDocument: { "gpt-6-astra": { input_cost_per_token: 0, output_cost_per_token: 1, + input_cost_per_token_priority: 0, + output_cost_per_token_priority: 2, input_cost_per_token_ultrafast: 0, output_cost_per_token_ultrafast: 6, }, @@ -1760,46 +2167,49 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live.skipIf(HostProcess.Platform.defaultValue() === "win32")( - "keeps the later read when a scan that read earlier finishes first", - () => - Effect.gen(function* () { - const { transcript, settings, home } = yield* setup; - const dir = NodePath.dirname(transcript); - const gate = (name: string) => NodePath.join(dir, `${name}.jsonl`); - const wideProbe = gate("wide-probe"); - const wideHold = gate("wide-hold"); - const narrowProbe = gate("narrow-probe"); - const narrowHold = gate("narrow-hold"); - yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); - yield* makeGate(wideProbe, BEFORE_NARROW_WINDOW); - yield* makeGate(wideHold, BEFORE_NARROW_WINDOW); - yield* Effect.gen(function* () { - const service = yield* UsageService.make; - const wide = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); - yield* openGate(wideProbe); - yield* replaceFile(transcript, claudeLine(1, 5) + claudeLine(2, 7)); - yield* makeGate(narrowProbe); - yield* makeGate(narrowHold); - const narrow = yield* service.readSummary(NARROW_WINDOW).pipe(Effect.forkChild); - yield* openGate(narrowProbe); - yield* openGate(wideHold); - yield* Fiber.join(wide); - yield* openGate(narrowHold); - yield* Fiber.join(narrow); - - yield* Effect.promise(() => - Promise.all( - [transcript, wideProbe, wideHold, narrowProbe, narrowHold].map((path) => - NodeFSP.rm(path), - ), - ), - ); - assert.strictEqual(totalOutputTokens(yield* service.readSummary(WINDOW)), 12); - }).pipe( - Effect.provide(layerService({ prefix: "usage-service-late-read-test", home, settings })), + it.live("keeps an absent directory missing when its cached files only contain diagnostics", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => + NodeFSP.rm(NodePath.join(home, "claude", "projects"), { recursive: true }), + ); + yield* Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const mtimeMs = yield* Clock.currentTimeMillis; + const cache = encodeScanCache( + new Map([ + [ + transcript, + { + size: 0, + mtimeMs, + provider: "claude" as const, + records: [], + tailRecords: [], + malformedRecords: 2, + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + }, + ], + ]), ); - }).pipe(Effect.scoped), + yield* Effect.promise(async () => { + await NodeFSP.mkdir(stateDir, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(stateDir, SCAN_CACHE_FILE_NAME), + encodeUnknownJsonString(cache), + ); + }); + const summary = yield* (yield* UsageService.make).readSummary(WINDOW); + const source = summary.sources.find((source) => source.fingerprint.provider === "claude"); + assert.strictEqual(source?.status, "missing"); + assert.strictEqual(source?.scannedFiles, 0); + assert.strictEqual(source?.malformedRecords, 2); + }).pipe( + Effect.provide( + layerService({ prefix: "usage-service-missing-diagnostics", home, settings }), + ), + ); + }).pipe(Effect.scoped), ); it.live("reports saved usage of a removed directory only for windows it reaches", () => @@ -1894,119 +2304,6 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("shares one scan between concurrent identical requests", () => - Effect.gen(function* () { - const { transcript, settings, home } = yield* setup; - yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); - - let ratesFetches = 0; - const service = yield* UsageService.make.pipe( - Effect.provide( - layerService({ - prefix: "usage-service-flight-test", - home, - settings, - onRatesFetch: () => { - ratesFetches += 1; - }, - }), - ), - ); - - const [first, second] = yield* Effect.all( - [service.readSummary(WINDOW), service.readSummary(WINDOW)], - { concurrency: 2 }, - ); - assert.deepStrictEqual(first, second); - assert.strictEqual(ratesFetches, 2); - - yield* service.readSummary(WINDOW); - assert.strictEqual(ratesFetches, 2); - }).pipe(Effect.scoped), - ); - - it.live("refetches a rate table inside its TTL only when the client asks", () => - Effect.gen(function* () { - const { transcript, settings, home } = yield* setup; - yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); - - let ratesFetches = 0; - const service = yield* UsageService.make.pipe( - Effect.provide( - layerService({ - prefix: "usage-service-rates-refresh-test", - home, - settings, - ratesDocument: { - "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, - }, - onRatesFetch: () => { - ratesFetches += 1; - }, - }), - ), - ); - - const first = yield* service.readSummary(WINDOW); - assert.strictEqual(ratesFetches, 1); - assert.strictEqual(first.pricing.status, "fresh"); - - yield* TestClock.adjust(Duration.minutes(2)); - yield* service.readSummary(WINDOW); - assert.strictEqual(ratesFetches, 1); - - const [refreshed] = yield* Effect.all([service.refreshRates, service.refreshRates], { - concurrency: 2, - }); - assert.strictEqual(ratesFetches, 2); - assert.strictEqual(refreshed.status, "fresh"); - assert.strictEqual(refreshed.knownModels, 1); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - - it.live("preserves cached estimates when both pricing downloads fail and recovers", () => - Effect.gen(function* () { - const { transcript, settings, home } = yield* setup; - yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); - let status = 200; - const requests: string[] = []; - const service = yield* UsageService.make.pipe( - Effect.provide( - layerService({ - prefix: "usage-service-rates-failure-test", - home, - settings, - ratesDocument: { - "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, - }, - ratesStatus: () => status, - onRatesFetch: (request) => requests.push(request.url), - }), - ), - ); - const first = yield* service.readSummary(WINDOW); - yield* TestClock.adjust(Duration.minutes(2)); - status = 503; - - const failed = yield* service.refreshRates; - assert.strictEqual(failed.status, "cached"); - assert.strictEqual(failed.fetchedAt, first.pricing.fetchedAt); - assert.strictEqual(requests.length, 3); - assert.strictEqual( - requests[2], - "https://api.github.com/repos/BerriAI/litellm/contents/model_prices_and_context_window.json?ref=main", - ); - const cached = yield* service.readSummary(WINDOW); - assert.deepStrictEqual(cached.buckets, first.buckets); - - status = 200; - yield* TestClock.adjust(Duration.minutes(1)); - const recovered = yield* service.refreshRates; - assert.strictEqual(recovered.status, "fresh"); - assert.notStrictEqual(recovered.fetchedAt, first.pricing.fetchedAt); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - it.live.each(["network", "http", "invalidJson", "empty"] as const)( "recovers pricing through the GitHub API after a %s failure", (failure) => @@ -2066,126 +2363,6 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("tries the GitHub API after the primary pricing request times out", () => - Effect.gen(function* () { - const { settings, home } = yield* setup; - const primaryStarted = yield* Deferred.make(); - const service = yield* UsageService.make.pipe( - Effect.provide( - layerService({ - prefix: "usage-service-rates-timeout-test", - home, - settings, - ratesResponse: (request) => - request.url.startsWith("https://api.github.com/") - ? Effect.succeed( - Response.json({ - "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, - }), - ) - : Deferred.succeed(primaryStarted, undefined).pipe(Effect.andThen(Effect.never)), - }), - ), - ); - const refresh = yield* service.refreshRates.pipe(Effect.forkChild); - yield* Deferred.await(primaryStarted); - yield* TestClock.adjust(Duration.seconds(10)); - const pricing = yield* Fiber.join(refresh); - assert.strictEqual(pricing.status, "fresh"); - assert.strictEqual(pricing.knownModels, 1); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - - it.live("keeps pricing unavailable when neither download has usable rates", () => - Effect.gen(function* () { - const { settings, home } = yield* setup; - const service = yield* UsageService.make.pipe( - Effect.provide(layerService({ prefix: "usage-service-rates-empty-test", home, settings })), - ); - const pricing = yield* service.refreshRates; - assert.strictEqual(pricing.status, "unavailable"); - assert.isNull(pricing.fetchedAt); - }).pipe(Effect.scoped), - ); - - it.live.each([false, true])( - "throttles failed pricing loads from completion with cached rates: %s", - (hasCachedRates) => - Effect.gen(function* () { - const { settings, home } = yield* setup; - const primaryStarted = yield* Deferred.make(); - const fallbackStarted = yield* Deferred.make(); - let responding = true; - let requests = 0; - const service = yield* UsageService.make.pipe( - Effect.provide( - layerService({ - prefix: "usage-service-rates-retry-floor-test", - home, - settings, - onRatesFetch: () => { - requests += 1; - }, - ratesResponse: (request) => - responding - ? Effect.succeed( - Response.json({ - "claude-fable-5": { - input_cost_per_token: 1e-5, - output_cost_per_token: 5e-5, - }, - }), - ) - : Deferred.succeed( - request.url.startsWith("https://api.github.com/") - ? fallbackStarted - : primaryStarted, - undefined, - ).pipe(Effect.andThen(Effect.never)), - }), - ), - ); - let fetchedAt: string | null = null; - if (hasCachedRates) { - fetchedAt = (yield* service.refreshRates).fetchedAt; - yield* TestClock.adjust(Duration.hours(24)); - } - responding = false; - const refresh = yield* service.refreshRates.pipe(Effect.forkChild); - yield* Deferred.await(primaryStarted); - yield* TestClock.adjust(Duration.seconds(10)); - yield* Deferred.await(fallbackStarted); - yield* TestClock.adjust(Duration.seconds(10)); - const failed = yield* Fiber.join(refresh); - const expectedStatus = hasCachedRates ? "cached" : "unavailable"; - assert.strictEqual(failed.status, expectedStatus); - assert.strictEqual(failed.fetchedAt, fetchedAt); - const requestsAfterFailure = requests; - assert.strictEqual(requestsAfterFailure, hasCachedRates ? 3 : 2); - - responding = true; - const [immediate, summary] = yield* Effect.all( - [service.refreshRates, service.readSummary(WINDOW)], - { concurrency: 2 }, - ); - assert.strictEqual(immediate.status, expectedStatus); - assert.strictEqual(summary.pricing.status, expectedStatus); - assert.strictEqual(requests, requestsAfterFailure); - yield* TestClock.adjust(Duration.seconds(59)); - const coolingDown = yield* service.refreshRates; - assert.strictEqual(coolingDown.status, expectedStatus); - assert.strictEqual(coolingDown.fetchedAt, fetchedAt); - assert.strictEqual(requests, requestsAfterFailure); - - yield* TestClock.adjust(Duration.seconds(1)); - const recovered = yield* service.refreshRates; - assert.strictEqual(recovered.status, "fresh"); - assert.strictEqual(recovered.knownModels, 1); - assert.notStrictEqual(recovered.fetchedAt, fetchedAt); - assert.strictEqual(requests, requestsAfterFailure + 1); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - it.live("does not orphan an in-flight scan when its first caller is interrupted", () => Effect.gen(function* () { const { settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 49d8d179664b..831a0b1997e7 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -50,7 +50,7 @@ import { import { decodeScanCache, dedupeWithinFile, - LEGACY_SCAN_CACHE_FILE_NAME, + LEGACY_SCAN_CACHE_FILE_NAMES, makeScanCacheWriter, pruneScanCache, SCAN_CACHE_FILE_NAME, @@ -195,6 +195,11 @@ export const make = Effect.gen(function* () { const readerContext = yield* Effect.context(); const fileCache: ScanCache = new Map(); + // Reads from concurrent windows can still hold the original record after it is corrected. + const resolvedRecords = new WeakMap(); + const correctedRecord = (record: UsageRecord) => resolvedRecords.get(record) ?? record; + const correctedBatch = (records: readonly UsageRecord[]) => + records.some((record) => resolvedRecords.has(record)) ? records.map(correctedRecord) : records; const sourceCache = new Map(); let cacheDirty = false; const isWithinDirectory = (filePath: string, dir: string) => { @@ -204,7 +209,6 @@ export const make = Effect.gen(function* () { const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME); - const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME); const writeCacheFile = (filePath: string, contents: string) => writeFileStringAtomically({ filePath, contents }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), @@ -353,7 +357,7 @@ export const make = Effect.gen(function* () { ([filePath, entry]) => entry.provider === provider && entry.mtimeMs >= retentionCutoffMs && - entry.records.length + entry.tailRecords.length > 0 && + (entry.records.length + entry.tailRecords.length > 0 || entry.malformedRecords > 0) && isWithinDirectory(filePath, dir), ); const volumeId = @@ -392,8 +396,9 @@ export const make = Effect.gen(function* () { Effect.catchCause(() => Effect.succeed(null)), ); let document = yield* readDocument(scanCachePath); - if (document === null) { - document = yield* readDocument(legacyScanCachePath); + for (const fileName of LEGACY_SCAN_CACHE_FILE_NAMES) { + if (document !== null) break; + document = yield* readDocument(path.join(config.stateDir, fileName)); cacheDirty = document !== null; } if (document === null) return; @@ -451,66 +456,102 @@ export const make = Effect.gen(function* () { * than completion order: saved records of deleted transcripts aggregate in * cache order, where the first copy of a duplicate wins. */ - const readFileRecords = ( + const readFileRecords = Effect.fn("UsageService.readFileRecords")(function* ( filePath: string, size: number, mtimeMs: number, provider: UsageProviderKind, format: TranscriptUsageFormat, - ): Effect.Effect<{ + ): Effect.fn.Return<{ readonly records: readonly UsageRecord[]; + readonly malformedRecords: number; readonly failed?: true; readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined }; - }> => - Effect.gen(function* () { - const cached = fileCache.get(filePath); - // Provider is part of the identity: if both providers were ever pointed - // at one directory, a hit parsed by the other parser must not be reused. - if ( - cached && - cached.size === size && - cached.mtimeMs === mtimeMs && - cached.provider === provider - ) { - return { - records: - cached.tailRecords.length === 0 - ? cached.records - : [...cached.records, ...cached.tailRecords], - }; - } - - // Only a strictly grown file may resume. Same size with a new mtime, or - // a shrunken file, means rewritten content; re-parse it whole. - const resumeFrom = - cached !== undefined && cached.provider === provider && size > cached.size - ? cached.position - : undefined; + }> { + const cached = fileCache.get(filePath); + // Provider is part of the identity: if both providers were ever pointed + // at one directory, a hit parsed by the other parser must not be reused. + if ( + cached && + cached.size === size && + cached.mtimeMs === mtimeMs && + cached.provider === provider + ) { + return { + records: + cached.tailRecords.length === 0 + ? cached.records + : [...cached.records, ...cached.tailRecords], + malformedRecords: cached.malformedRecords, + }; + } - const parsed = yield* Effect.promise(() => - readTranscriptRecords(filePath, format, resumeFrom), - ); - // A read failure is not an empty transcript: caching it under this - // (size, mtime) would silently drop the file's usage until it changes. - if (parsed === null) - return { - records: cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : [], - failed: true, - }; + // Only a strictly grown file may resume. Same size with a new mtime, or + // a shrunken file, means rewritten content; re-parse it whole. + const resumeFrom = + cached !== undefined && cached.provider === provider && size > cached.size + ? cached.position + : undefined; + + const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, format, resumeFrom)); + // A read failure is not an empty transcript: caching it under this + // (size, mtime) would silently drop the file's usage until it changes. + if (parsed === null) + return { + records: cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : [], + malformedRecords: cached?.provider === provider ? cached.malformedRecords : 0, + failed: true, + }; - const base = parsed.resumed && cached !== undefined ? cached.records : []; - const seen = new Set(); - const records = dedupeWithinFile([...base, ...parsed.records], seen); - const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); + if (parsed.resumed && cached !== undefined && cached.tailRecords.length > 0) { + // Completing a tentative line reparses it. Reuse its saved correction only when the + // same usage is emitted again, without putting inferred tiers in the parser state. + const reparsed = [...parsed.records, ...parsed.tailRecords]; + const identity = (record: UsageRecord) => + encodeUsageRecordKey([ + record.provider, + record.sessionId, + record.timestampMs, + record.model, + record.rateModel, + record.totals, + record.reportedCostUsd, + record.dedupeKey, + ]); + for (const [index, previous] of cached.tailRecords.entries()) { + const record = reparsed[index]; + const corrected = correctedRecord(previous); + if ( + record?.unresolvedServiceTierTurnId !== undefined && + corrected.unresolvedServiceTierTurnId === undefined && + identity(record) === identity(corrected) + ) { + resolvedRecords.set(record, corrected); + } + } + } + const base = parsed.resumed && cached !== undefined ? cached.records : []; + const seen = new Set(); + const records = dedupeWithinFile([...base, ...parsed.records], seen); + const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); - return { - records: tailRecords.length === 0 ? records : [...records, ...tailRecords], - update: { - entry: { size, mtimeMs, provider, records, tailRecords, position: parsed.position }, - replaces: cached, + return { + records: tailRecords.length === 0 ? records : [...records, ...tailRecords], + malformedRecords: parsed.malformedRecords, + update: { + entry: { + size, + mtimeMs, + provider, + records, + tailRecords, + malformedRecords: parsed.malformedRecords, + position: parsed.position, }, - }; - }); + replaces: cached, + }, + }; + }); interface ScannedDir { readonly provider: UsageProviderKind; @@ -521,7 +562,11 @@ export const make = Effect.gen(function* () { readonly message?: string; readonly action?: UsageSource["action"]; readonly files: - | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] + | readonly { + readonly path: string; + readonly records: readonly UsageRecord[]; + readonly malformedRecords: number; + }[] | null; readonly refreshing?: true; } @@ -546,8 +591,8 @@ export const make = Effect.gen(function* () { ), { concurrency: TRANSCRIPT_READ_CONCURRENCY }, ); - const parsedFiles = read.map(({ path, records, update }) => { - if (update === undefined) return { path, records }; + const parsedFiles = read.map(({ path, records, malformedRecords, update }) => { + if (update === undefined) return { path, records, malformedRecords }; // A scan of another window may have cached its own read of this file // meanwhile. Then keep whichever read saw the later file, so a slower // scan never replaces newer usage with older. @@ -555,12 +600,16 @@ export const make = Effect.gen(function* () { if ( current === update.replaces || current === undefined || - !isLaterRead(current, update.entry) + isLaterRead(update.entry, current) ) { - fileCache.set(path, update.entry); + fileCache.set(path, { + ...update.entry, + records: correctedBatch(update.entry.records), + tailRecords: correctedBatch(update.entry.tailRecords), + }); cacheDirty = true; } - return { path, records }; + return { path, records, malformedRecords }; }); const unread = failedPaths + read.filter((file) => file.failed).length; return { @@ -605,6 +654,7 @@ export const make = Effect.gen(function* () { : Effect.succeed(volumeId), (resolved): ScannedDir => ({ ...source, + files: source.files?.map((file) => ({ ...file, malformedRecords: 0 })) ?? null, provider: reader.provider, volumeId: resolved, }), @@ -713,10 +763,57 @@ export const make = Effect.gen(function* () { !isWithinDirectory(filePath, dir) ) continue; - retainedFiles.push({ path: filePath, records: [...entry.records, ...entry.tailRecords] }); + retainedFiles.push({ + path: filePath, + records: [...entry.records, ...entry.tailRecords], + malformedRecords: entry.malformedRecords, + }); } return retainedFiles; }); + const windowEndMs = + hourlyWindow?.untilTimeMs ?? + Date.parse(`${input.untilDay}T00:00:00Z`) + 86400000 + MTIME_SLACK_MS; + let resolvedAny = false; + for (const { reader } of transcriptReaders) { + if (reader.resolveServiceTiers === undefined) continue; + const pending = new Set(); + for (const [index, files] of filesByDir.entries()) { + if (scannedDirs[index]?.provider !== reader.provider) continue; + for (const file of files) { + for (const raw of file.records) { + const record = correctedRecord(raw); + if ( + record.provider === reader.provider && + record.unresolvedServiceTierTurnId !== undefined && + record.timestampMs >= windowStartMs && + record.timestampMs < windowEndMs + ) { + pending.add(record); + } + } + } + } + if (pending.size === 0) continue; + const resolved = yield* reader + .resolveServiceTiers([...pending]) + .pipe(Effect.provideContext(readerContext)); + for (const [record, speed] of resolved) { + const { unresolvedServiceTierTurnId: _turnId, ...rest } = record; + resolvedRecords.set(record, { ...rest, speed }); + resolvedAny = true; + } + } + if (resolvedAny) { + for (const [filePath, entry] of fileCache) { + const records = correctedBatch(entry.records); + const tailRecords = correctedBatch(entry.tailRecords); + if (records === entry.records && tailRecords === entry.tailRecords) continue; + // Update the current entry by identity; a slow resolver cannot restore an older file. + fileCache.set(filePath, { ...entry, records, tailRecords }); + cacheDirty = true; + } + } const crossFileSessions = sharedSessions(filesByDir.flat()); for (const [ @@ -725,18 +822,21 @@ export const make = Effect.gen(function* () { ] of scannedDirs.entries()) { let scannedFiles = 0; let skippedFiles = 0; + let malformedRecords = 0; // Distinct per directory. Buckets carry per-cell session counts, but a // session spans days and models, so clients total this figure instead. const sessionIds = new Set(); for (const file of filesByDir[index] ?? []) { + malformedRecords += file.malformedRecords; if (file.records.length === 0) { skippedFiles += 1; continue; } scannedFiles += 1; const eventOccurrences = new Map(); - for (const record of file.records) { + for (const rawRecord of file.records) { + const record = correctedRecord(rawRecord); let usageRecord = record; if ( sharedSessionProviders.has(record.provider) && @@ -761,15 +861,31 @@ export const make = Effect.gen(function* () { } } + const sourceMessage = + message ?? (files === null ? "No transcript directory on this environment." : null); sources.push({ fingerprint: { hostId: sourceHostId ?? hostId, provider, resolvedHomePath: dir, volumeId }, - status: files === null && scannedFiles === 0 ? "missing" : (status ?? "ok"), + status: + files === null && scannedFiles === 0 + ? "missing" + : malformedRecords > 0 + ? status === undefined || status === "ok" + ? "partial" + : status + : (status ?? "ok"), scannedFiles, skippedFiles, - malformedRecords: 0, + malformedRecords, distinctSessions: sessionIds.size, message: - message ?? (files === null ? "No transcript directory on this environment." : null), + malformedRecords > 0 + ? [ + sourceMessage, + `${malformedRecords} malformed or inconsistent usage record(s) were skipped; usage may be incomplete.`, + ] + .filter(Boolean) + .join(" ") + : sourceMessage, ...(action ? { action } : {}), ...(refreshing ? { refreshing } : {}), }); diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 82521ac8c645..ba7f5de41ace 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -1,7 +1,6 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http"; @@ -49,7 +48,12 @@ const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); function fixture( options: { - accounts?: Array<(typeof accounts)[number] & { disabled?: boolean }>; + accounts?: Array< + (typeof accounts)[number] & { + disabled?: boolean; + id_token: { chatgpt_account_id: string; plan_type?: string; chatgpt_plan_type?: string }; + } + >; upstream?: (request: RequestBody) => { status: number; body: unknown }; cooldownStatus?: number; } = {}, @@ -152,6 +156,7 @@ describe("CLIProxyAPI hub failures", () => { }), ); + // regression: #18168 Limits hub failures name their TLS, HTTP, or timeout cause it.effect("ignores a transport code that is not a short Node error code", () => Effect.gen(function* () { const api = yield* unreachableHub( @@ -187,40 +192,6 @@ describe("CLIProxyAPI hub failures", () => { expect(error.detail).not.toContain("management-secret"); }), ); - - it.effect("says when the hub does not answer in time", () => - Effect.gen(function* () { - const api = yield* makeCliproxyApi.pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.never), - ), - Effect.provide(NodeCrypto.layer), - ); - const pending = yield* Effect.flip(api.readAccounts(config)).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* TestClock.adjust("15 seconds"); - const error = yield* Fiber.join(pending); - expect(error.detail).toContain("did not answer within 15 seconds"); - }), - ); - - it.effect("keeps a malformed account list generic", () => - Effect.gen(function* () { - const test = HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb(request, Response.json({ nope: "hub-internal" })), - ), - ); - const api = yield* makeCliproxyApi.pipe( - Effect.provideService(HttpClient.HttpClient, test), - Effect.provide(NodeCrypto.layer), - ); - const error = yield* Effect.flip(api.readAccounts(config)); - expect(error.detail).toBe("The hub could not list accounts."); - }), - ); }); describe("CLIProxyAPI built-in management API", () => { @@ -254,6 +225,46 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect("resolves a missing usage plan from current, then legacy, token plans", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [ + { ...accounts[0]!, id_token: { chatgpt_account_id: "account-a", plan_type: "free" } }, + { + ...accounts[1]!, + id_token: { + chatgpt_account_id: "account-b", + plan_type: "go", + chatgpt_plan_type: "plus", + }, + }, + { + ...accounts[1]!, + id: "third.json", + auth_index: "c", + id_token: { chatgpt_account_id: "account-c", chatgpt_plan_type: "free" }, + }, + ], + upstream: (request) => ({ + status: 200, + body: { + ...(request.auth_index === "a" ? { plan_type: "plus" } : {}), + rate_limit: { primary_window: { used_percent: 12 } }, + }, + }), + }); + const api = yield* test.api; + const result = yield* api.readAccounts(config); + expect(result.map((account) => [account.plan, account.usageLimits.windows[0]?.kind])).toEqual( + [ + ["ChatGPT Plus Subscription", "session"], + ["ChatGPT Go Subscription", "monthly"], + ["ChatGPT Free Subscription", "monthly"], + ], + ); + }), + ); + it.effect("keeps the path of a hub served under a prefix", () => Effect.gen(function* () { const test = fixture(); @@ -359,21 +370,6 @@ describe("CLIProxyAPI built-in management API", () => { }), ); - it.effect.each([ - ["nothing_to_reset", "nothingToReset"], - ["no_credit", "noCredit"], - ["already_redeemed", "alreadyRedeemed"], - ] as const)("reports %s accurately", ([code, outcome]) => - Effect.gen(function* () { - const test = fixture({ upstream: () => ({ status: 200, body: { code } }) }); - const api = yield* test.api; - expect(yield* api.consume(config, "first.json", "credit")).toEqual({ outcome }); - expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe( - code === "already_redeemed", - ); - }), - ); - it.effect("reports redemption success even if cooldown clearing fails", () => Effect.gen(function* () { const api = yield* fixture({ cooldownStatus: 404 }).api; diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index d386b7e9bd88..f097aa7a358c 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -28,6 +28,7 @@ const AuthFile = Schema.Struct({ Schema.Struct({ chatgpt_account_id: Schema.optional(Schema.String), chatgpt_plan_type: Schema.optional(Schema.String), + plan_type: Schema.optional(Schema.String), }), ), }); @@ -305,14 +306,16 @@ export const makeCliproxyApi = Effect.gen(function* () { : null; const available = yield* credits(config, account).pipe(Effect.orElseSucceed(() => undefined)); const next = available?.[0]; + const planType = + usage.plan_type ?? account.id_token?.plan_type ?? account.id_token?.chatgpt_plan_type; return { ...base, - plan: codexPlanLabel(usage.plan_type ?? account.id_token?.chatgpt_plan_type), + plan: codexPlanLabel(planType), usageLimits: { ...codexRateLimitsToLimits({ checkedAt, snapshot: { - planType: usage.plan_type ?? null, + planType: planType ?? null, primary: toWindow(usage.rate_limit?.primary_window), secondary: toWindow(usage.rate_limit?.secondary_window), }, diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index b89c1fe70223..08b3f3ae1b8c 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -68,19 +68,6 @@ function aggregate( } describe("UsageAggregator", () => { - it("requires exact bounds for hourly aggregation", () => { - expect( - () => - new UsageAggregator({ - timeZone: "UTC", - sinceDay: "2026-08-01", - untilDay: "2026-08-31", - resolution: "hour", - rates, - }), - ).toThrow("requires exact time bounds"); - }); - it("splits a bucket's cost by category and speed", () => { const [bucket] = aggregate([record(), record({ speed: "fast" })]).buckets; @@ -111,13 +98,6 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.totals.outputTokens).toBe(50); }); - it("still sums records that carry no dedupe key", () => { - const result = aggregate([record(), record()]); - - expect(result.duplicatesDropped).toBe(0); - expect(result.buckets[0]?.totals.outputTokens).toBe(100); - }); - it("buckets by the day in the requested time zone", () => { const utc = aggregate([record()], "UTC"); const losAngeles = aggregate([record()], "America/Los_Angeles"); @@ -194,24 +174,6 @@ describe("UsageAggregator", () => { ]); }); - it("keeps daily payloads collapsed when hourly resolution is not requested", () => { - const result = aggregate([ - record({ timestampMs: Date.parse("2026-08-07T04:05:13.944Z") }), - record({ timestampMs: Date.parse("2026-08-07T05:05:13.944Z") }), - ]); - - expect(result.buckets).toHaveLength(1); - expect(result.buckets[0]?.hourStart).toBeUndefined(); - expect(result.buckets[0]?.records).toBe(2); - }); - - it("prices against the rate table", () => { - const result = aggregate([record()]); - - expect(result.buckets[0]?.costUsd).toBeCloseTo(0.004625, 9); - expect(result.buckets[0]?.costSource).toBe("modelPriced"); - }); - it("counts tokens but not cost for a model with no rate", () => { const result = aggregate([record({ model: "kimi-k3" })]); @@ -228,26 +190,6 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.costSource).toBe("providerReported"); }); - it("drops records outside the window", () => { - const result = aggregate([record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") })]); - - expect(result.outOfWindow).toBe(1); - expect(result.buckets).toHaveLength(0); - }); - - it("reports whether a record contributed", () => { - const aggregator = new UsageAggregator({ - timeZone: "UTC", - sinceDay: "2026-08-01", - untilDay: "2026-08-31", - rates, - }); - - expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true); - expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(false); - expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false); - }); - it("folds a mapped model into its target and prices it there", () => { const aggregator = new UsageAggregator({ timeZone: "UTC", @@ -266,16 +208,6 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.costUsd).toBeCloseTo(0.00925, 9); expect(result.buckets[0]?.unpricedRecords).toBe(0); }); - - it("separates providers and models into their own buckets", () => { - const result = aggregate([ - record(), - record({ provider: "codex", model: "gpt-5.6-sol" }), - record({ model: "claude-opus-5" }), - ]); - - expect(result.buckets).toHaveLength(3); - }); }); describe("resolveModelAliases", () => { diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index fac9cc3e0341..231454d7c175 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -2,16 +2,15 @@ import { describe, expect, it } from "@effect/vitest"; import { decodeScanCache, - dedupeWithinFile, encodeScanCache, makeScanCacheWriter, - pruneScanCache, type CachedFile, type ScanCache, } from "./usageScanCache.ts"; import type { UsageRecord } from "@t3tools/provider-core/server/usage"; import { TEST_FORMAT_MAP } from "./usageTestFormats.ts"; +import { initialCodexScanState, parseCodexLine } from "../provider/Drivers/codexUsage.ts"; function record(overrides: Partial = {}): UsageRecord { return { @@ -52,6 +51,7 @@ function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]) provider: "claude", records, tailRecords: [], + malformedRecords: 0, position: position(), }); } @@ -59,103 +59,177 @@ function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]) } describe("scan cache round trip", () => { - it("restores records unchanged", () => { + it("restores cached history unchanged except for Claude local placeholders", () => { const original = cacheWith([ [ "/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5-5", speed: "fast" })], ], - ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], + [ + "/b.jsonl", + 200, + [ + record({ + sessionId: "session-b", + reportedCostUsd: 1.5, + totals: { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, + }), + ], + ], ]); original.set("/grok.jsonl", { size: 40, + malformedRecords: 2, mtimeMs: 300, provider: "grok", records: [ record({ provider: "grok", model: "grok-4.5-build", dedupeKey: "s:p:grok-4.5-build" }), + record({ provider: "grok", model: "", dedupeKey: "s:p:" }), ], - tailRecords: [record({ provider: "grok", model: "grok-4.5-build", dedupeKey: null })], + tailRecords: [record({ provider: "grok", model: "", dedupeKey: null })], position: position({ resumeOffset: 30, guardLength: 30, guardHash: 123 }), }); + const state = initialCodexScanState(); + for (const line of [ + { type: "session_meta", payload: { id: "session-c", model_provider: "openai" } }, + { type: "turn_context", payload: { model: "gpt-6-astra", turn_id: "native-turn" } }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 1, output_tokens: 2 } }, + }, + }, + ]) + parseCodexLine(JSON.stringify(line), state); + const pending = { + ...record({ + provider: "codex", + model: "gpt-6-astra", + sessionId: "session-c", + dedupeKey: null, + }), + unresolvedServiceTierTurnId: "native-turn", + }; + const pendingTail = { ...pending, unresolvedServiceTierTurnId: "native-tail" }; original.set("/codex.jsonl", { size: 80, + malformedRecords: 3, mtimeMs: 400, provider: "codex", records: [ - record({ provider: "codex", model: "gpt-6-astra", dedupeKey: null, speed: "ultrafast" }), - ], - tailRecords: [], - position: position({ - state: { + record({ + provider: "codex", model: "gpt-6-astra", + rateModel: "gpt-6", + dedupeKey: null, speed: "ultrafast", - sessionId: "session-c", - lastUsageSignature: '{"input_tokens":1}', - sawSessionMeta: true, - suppressingForkCopies: false, - forkCopyAnchorMs: 0, - }, - }), + }), + pending, + ], + tailRecords: [pendingTail], + position: position({ state }), }); + original.set("/a.jsonl", { + ...original.get("/a.jsonl")!, + tailRecords: [record({ dedupeKey: "msg_tail:" })], + }); + const claude = original.get("/a.jsonl")!; + const cached = new Map(original); + cached.set("/a.jsonl", { + ...claude, + records: [...claude.records, record({ model: "" })], + tailRecords: [ + ...claude.tailRecords, + record({ model: "", dedupeKey: "msg_tail:" }), + ], + }); const restored = decodeScanCache( - JSON.parse(JSON.stringify(encodeScanCache(original))), + JSON.parse(JSON.stringify(encodeScanCache(cached))), TEST_FORMAT_MAP, ); expect(restored.size).toBe(4); - expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); - expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl")); - expect(restored.get("/grok.jsonl")).toEqual(original.get("/grok.jsonl")); - expect(restored.get("/codex.jsonl")).toEqual(original.get("/codex.jsonl")); - }); - - it("drops an entry whose persisted parse state is corrupt", () => { - const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); - const poisoned = { - ...encoded, - files: { - "/a.jsonl": { ...encoded.files["/a.jsonl"]!, cs: { model: 42 } }, - }, - }; - - expect( - decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).has("/a.jsonl"), - ).toBe(false); + expect(restored).toEqual(original); }); - it("drops an entry whose guard length is outside the supported range", () => { - const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); - const poisoned = { - ...encoded, - files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, gl: 1e20 } }, - }; - - expect( - decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).has("/a.jsonl"), - ).toBe(false); - }); - - it("drops an entry whose speed is not a known index", () => { - const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record({ speed: "fast" })]]])); - const row = encoded.files["/a.jsonl"]!.r[0]!; - const poisoned = { - ...encoded, - files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, r: [[...row.slice(0, 10), true]] } }, - }; - - expect( - decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).has("/a.jsonl"), - ).toBe(false); - }); - - it("rejects a document from before records carried a speed", () => { - const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); - const previous = { ...encoded, version: 3 }; - - expect(decodeScanCache(JSON.parse(JSON.stringify(previous)), TEST_FORMAT_MAP).size).toBe(0); - }); + it.each([5, 6, 7])( + "retains v%s history and compatible positions but invalidates obsolete state", + (version) => { + const original = cacheWith([["/gone.jsonl", 100, [record({ speed: "fast" })]]]); + original.set("/gone-codex.jsonl", { + ...original.get("/gone.jsonl")!, + provider: "codex", + records: [record({ provider: "codex", speed: "fast", dedupeKey: null })], + position: position({ + state: { + ...(version >= 6 ? initialCodexScanState() : {}), + model: "gpt-6-astra", + speed: "fast", + sessionId: "session-a", + lastUsageSignature: null, + sawSessionMeta: true, + suppressingForkCopies: false, + forkCopyAnchorMs: 0, + }, + }), + }); + original.set("/gone-pi.jsonl", { + ...original.get("/gone.jsonl")!, + size: 256, + provider: "pi", + records: [record({ provider: "pi", model: "anthropic/claude-fable-5" })], + malformedRecords: 3, + position: position({ state: { sessionId: "session-a" } }), + }); + const encoded = encodeScanCache(original); + const entry = encoded.files["/gone.jsonl"]!; + const legacy = { + ...encoded, + version, + files: { + ...encoded.files, + "/gone.jsonl": { + s: entry.s, + m: entry.m, + p: entry.p, + r: entry.r, + t: entry.t, + o: entry.o, + gl: entry.gl, + gh: entry.gh, + cs: entry.cs, + }, + }, + }; + + const restored = decodeScanCache(JSON.parse(JSON.stringify(legacy)), TEST_FORMAT_MAP); + expect(restored.get("/gone.jsonl")).toEqual(original.get("/gone.jsonl")); + expect(restored.get("/gone-codex.jsonl")).toEqual({ + ...original.get("/gone-codex.jsonl"), + size: -1, + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + }); + expect(restored.get("/gone-pi.jsonl")).toEqual( + version >= 6 + ? original.get("/gone-pi.jsonl") + : { + ...original.get("/gone-pi.jsonl"), + size: -1, + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + }, + ); + }, + ); it("rewrites only changed entries and still restores the whole cache", () => { const write = makeScanCacheWriter(); @@ -163,12 +237,14 @@ describe("scan cache round trip", () => { ["/a.jsonl", 100, [record()]], ["/b.jsonl", 200, [record({ sessionId: "session-b" })]], ]); + cache.set("/b.jsonl", { ...cache.get("/b.jsonl")!, malformedRecords: 2 }); const sources = { "claude\u0000/projects": { dir: "/projects", volumeId: "1:2" } }; expect(decodeScanCache(JSON.parse(write(cache, { sources })), TEST_FORMAT_MAP)).toEqual(cache); cache.set("/b.jsonl", { ...cache.get("/b.jsonl")!, size: 30, + malformedRecords: 4, records: [record({ sessionId: "session-c", model: "claude-opus-5-5", dedupeKey: "msg_3:" })], }); const document = JSON.parse(write(cache, { sources })); @@ -176,15 +252,6 @@ describe("scan cache round trip", () => { expect(document.sources).toEqual(sources); }); - it("interns repeated model and session strings", () => { - const encoded = encodeScanCache( - cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" }), record()]]]), - ); - - expect(encoded.models).toEqual(["claude-fable-5"]); - expect(encoded.sessions).toEqual(["session-a"]); - }); - it("treats a corrupt or foreign document as an empty cache", () => { expect(decodeScanCache(null, TEST_FORMAT_MAP).size).toBe(0); expect(decodeScanCache("nonsense", TEST_FORMAT_MAP).size).toBe(0); @@ -204,13 +271,6 @@ describe("scan cache round trip", () => { expect([...restored.keys()]).toEqual(["/good.jsonl"]); }); - it("rejects the whole cache when an intern table holds a non-string", () => { - const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); - const poisoned = { ...encoded, models: [1] }; - - expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).size).toBe(0); - }); - it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => { const encoded = encodeScanCache( cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" })]]]), @@ -230,43 +290,3 @@ describe("scan cache round trip", () => { expect(restored.has("/a.jsonl")).toBe(false); }); }); - -describe("pruneScanCache", () => { - const retentionCutoffMs = 1000; - - it("drops entries older than retention", () => { - const cache = cacheWith([["/old.jsonl", 500, [record()]]]); - - const removed = pruneScanCache(cache, retentionCutoffMs); - - expect(removed).toBe(1); - expect(cache.size).toBe(0); - }); - - it("keeps entries whose file has disappeared", () => { - const cache = cacheWith([["/gone.jsonl", 5000, [record()]]]); - - pruneScanCache(cache, retentionCutoffMs); - - expect(cache.size).toBe(1); - }); -}); - -describe("dedupeWithinFile", () => { - it("keeps the first record per dedupe key", () => { - const kept = dedupeWithinFile([ - record({ totals: { ...record().totals, outputTokens: 1 } }), - record({ totals: { ...record().totals, outputTokens: 999 } }), - record({ dedupeKey: "msg_2:" }), - ]); - - expect(kept).toHaveLength(2); - expect(kept[0]?.totals.outputTokens).toBe(1); - }); - - it("keeps every record that has no dedupe key", () => { - expect( - dedupeWithinFile([record({ dedupeKey: null }), record({ dedupeKey: null })]), - ).toHaveLength(2); - }); -}); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 2288ffafa74f..2cffb633debf 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -6,9 +6,10 @@ import type { } from "@t3tools/provider-core/server/usage"; import * as Schema from "effect/Schema"; +import { CLAUDE_SYNTHETIC_MODEL } from "../provider/Drivers/claudeUsage.ts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; -const USAGE_SCAN_CACHE_VERSION = 5 as const; +const USAGE_SCAN_CACHE_VERSION = 8 as const; const SPEED_COMPATIBLE_SINCE_VERSION = 4; /** @@ -16,8 +17,13 @@ const SPEED_COMPATIBLE_SINCE_VERSION = 4; * replace it, dropping saved usage for deleted transcripts. Separate files keep * both. */ -export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v5.json"; -export const LEGACY_SCAN_CACHE_FILE_NAME = "usage-scan-cache.json"; +export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v8.json"; +export const LEGACY_SCAN_CACHE_FILE_NAMES = [ + "usage-scan-cache-v7.json", + "usage-scan-cache-v6.json", + "usage-scan-cache-v5.json", + "usage-scan-cache.json", +] as const; const SPEEDS: readonly UsageSpeed[] = ["standard", "fast", "ultrafast"]; @@ -32,6 +38,8 @@ export interface CachedFile { * re-reads that segment and would otherwise double count it. */ readonly tailRecords: readonly UsageRecord[]; + /** Whole-file count, including diagnostics from the tentative tail. */ + readonly malformedRecords: number; readonly position: TranscriptParsePosition; } @@ -50,6 +58,7 @@ type SerializedRecord = readonly [ reportedCostUsd: number | null, speed: number, rateModelIndex?: number | null, + unresolvedServiceTierTurnId?: string, ]; interface SerializedFile { @@ -58,6 +67,7 @@ interface SerializedFile { readonly p: UsageProviderKind; readonly r: readonly SerializedRecord[]; readonly t: readonly SerializedRecord[]; + readonly mr: number; readonly o: number; readonly gl: number; readonly gh: number; @@ -107,6 +117,9 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile record.rateModel === undefined ? null : intern(tables.models, tables.modelIndex, record.rateModel), + ...(record.unresolvedServiceTierTurnId === undefined + ? ([] as const) + : ([record.unresolvedServiceTierTurnId] as const)), ]; return { s: entry.size, @@ -114,6 +127,7 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile p: entry.provider, r: entry.records.map(serializeRecord), t: entry.tailRecords.map(serializeRecord), + mr: entry.malformedRecords, o: entry.position.resumeOffset, gl: entry.position.guardLength, gh: entry.position.guardHash, @@ -217,6 +231,7 @@ export function decodeScanCache( reportedCostUsd, speedIndex, rateModelIndex, + unresolvedServiceTierTurnId, ] = row as SerializedRecord; const speed = typeof speedIndex === "number" ? SPEEDS[speedIndex] : undefined; @@ -226,6 +241,8 @@ export function decodeScanCache( typeof timestampMs !== "number" || !Number.isFinite(timestampMs) || model === undefined || + (unresolvedServiceTierTurnId !== undefined && + typeof unresolvedServiceTierTurnId !== "string") || (rateModelIndex != null && rateModel === undefined) || !Number.isFinite(uncached) || !Number.isFinite(cached) || @@ -237,6 +254,10 @@ export function decodeScanCache( return null; } + // Unchanged and deleted transcripts may only exist in the cache, so the + // parser cannot remove their previously saved local placeholders. + if (provider === "claude" && model === CLAUDE_SYNTHETIC_MODEL) continue; + records.push({ provider, timestampMs, @@ -252,6 +273,9 @@ export function decodeScanCache( }, reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null, speed, + ...(version >= 7 && unresolvedServiceTierTurnId !== undefined + ? { unresolvedServiceTierTurnId } + : {}), dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, }); } @@ -266,6 +290,8 @@ export function decodeScanCache( const format = formats.get(entry.p); if (format === undefined) continue; if (!isRecordArray(entry.r) || !isRecordArray(entry.t)) continue; + const malformedRecords = entry.mr === undefined ? 0 : entry.mr; + if (!Number.isSafeInteger(malformedRecords) || malformedRecords < 0) continue; // Position fields feed byte offsets and a Buffer allocation in the reader, // so anything outside their real ranges must reject the entry: a bogus // guard length would otherwise fail every parse of the file, silently @@ -284,11 +310,10 @@ export function decodeScanCache( ) { continue; } - // v4 records of stateful formats (Codex) predate service tiers, so they - // all priced as standard. Keep them, because the rollout may be gone, but - // make a live rollout re-parse whole: no file has size -1, and a zero - // position cannot resume. - const legacy = format.state !== undefined && version < USAGE_SCAN_CACHE_VERSION; + // Keep history for deleted rollouts, but re-parse incompatible reducer state. + // Older Codex entries lack provenance state (v7) or misread compaction and resume counters (v8). + const legacy = + format.state !== undefined && (version < 6 || (entry.p === "codex" && version < 8)); // A corrupt state disqualifies the entry: resuming with it would attach // appended usage to the wrong model or replay fork-copied history. if (!legacy && !isValidState.get(entry.p)?.(entry.cs)) continue; @@ -304,6 +329,7 @@ export function decodeScanCache( provider, records, tailRecords, + malformedRecords, position: legacy ? { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null } : { diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 3cd49d1cd1c9..c2eb20fa3066 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -6,6 +6,8 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; +import type { TranscriptUsageFormat } from "@t3tools/provider-core/server/usage"; +import * as Schema from "effect/Schema"; import { TEST_FORMATS } from "./usageTestFormats.ts"; import { readTranscriptRecords, transcriptLineGate } from "./usageTranscriptReader.ts"; @@ -70,6 +72,71 @@ function codexUsageLine(outputTokens: number, secondsOffset: number): string { } describe("readTranscriptRecords resume", () => { + it.each([ + { mode: "buffered", streamingThresholdBytes: Infinity }, + { mode: "streaming", streamingThresholdBytes: 0 }, + ])("counts malformed tails once across $mode reads and resume", async (options) => { + const format: TranscriptUsageFormat<{ errors: number }> = { + ...TEST_FORMATS.claude, + selectFields: { ...TEST_FORMATS.claude.selectFields, usage: true }, + state: { + initial: () => ({ errors: 0 }), + schema: Schema.Struct({ errors: Schema.Number }), + }, + parseLine(line, state) { + return this.parseProjected(JSON.parse(line), state); + }, + parseProjected(value, state) { + if ( + typeof value === "object" && + value !== null && + "usage" in value && + value.usage === "malformed" + ) { + state.errors += 1; + return []; + } + return TEST_FORMATS.claude.parseProjected(value, undefined); + }, + readMalformedRecords: (state: { errors: number }) => state.errors, + }; + const path = NodePath.join(dir, "diagnostics.jsonl"); + const malformed = JSON.stringify({ usage: "malformed" }); + // Completed lines enter the projected parser only when they cross a stream chunk. + const committed = `${JSON.stringify({ usage: "malformed", padding: "x".repeat(256 * 1024) })}\n${claudeLine(1, 5)}`; + await NodeFSP.writeFile(path, committed + malformed); + + const first = await readTranscriptRecords(path, format, undefined, options); + assert.isNotNull(first); + assert.deepStrictEqual( + first.records.map((record) => record.totals.outputTokens), + [5], + ); + assert.deepStrictEqual(first.tailRecords, []); + assert.strictEqual(first.position.resumeOffset, Buffer.byteLength(committed)); + assert.deepStrictEqual(first.position.state, { errors: 1 }); + assert.strictEqual(first.malformedRecords, 2); + + const savedPosition = JSON.parse(JSON.stringify(first.position)); + await NodeFSP.appendFile(path, `\n${claudeLine(2, 7)}${malformed}\n`); + const resumed = await readTranscriptRecords(path, format, savedPosition, options); + assert.isNotNull(resumed); + assert.isTrue(resumed.resumed); + assert.deepStrictEqual( + resumed.records.map((record) => record.totals.outputTokens), + [7], + ); + assert.deepStrictEqual(resumed.position.state, { errors: 3 }); + assert.strictEqual(resumed.malformedRecords, 3); + + const full = await readTranscriptRecords(path, format, undefined, options); + assert.isNotNull(full); + assert.deepStrictEqual([...first.records, ...resumed.records], full.records); + assert.deepStrictEqual(resumed.tailRecords, full.tailRecords); + assert.deepStrictEqual(resumed.position, full.position); + assert.strictEqual(resumed.malformedRecords, full.malformedRecords); + }); + it("parses only appended lines when resuming a grown file", async () => { const path = NodePath.join(dir, "claude.jsonl"); await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); @@ -77,6 +144,7 @@ describe("readTranscriptRecords resume", () => { assert.isNotNull(first); assert.strictEqual(first.records.length, 2); assert.isFalse(first.resumed); + assert.strictEqual(first.malformedRecords, 0); await NodeFSP.appendFile(path, claudeLine(3, 11)); const second = await readTranscriptRecords(path, TEST_FORMATS.claude, first.position); @@ -229,12 +297,6 @@ describe("readTranscriptRecords resume", () => { assert.strictEqual(result.records[0]?.sessionId, "codex-session-1"); assert.strictEqual(result.position.resumeOffset, Buffer.byteLength(content)); }); - - it("returns null for an unreadable file", async () => { - assert.isNull( - await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), TEST_FORMATS.claude), - ); - }); }); describe("transcriptLineGate", () => { @@ -244,17 +306,6 @@ describe("transcriptLineGate", () => { }; const markers = { one: ['"usage"'], several: ['"token_count"', '"session_meta"', '"a.b(c)"'] }; - it("passes only lines containing one of its markers", () => { - for (const kind of ["one", "several"] as const) { - for (const marker of markers[kind]) { - assert.isTrue(gates[kind](Buffer.from(`{"payload":{${marker}:1}}`))); - assert.isFalse(gates[kind](Buffer.from(`{"text":"see ${marker.slice(1, -1)} here"}`))); - assert.isFalse(gates[kind](Buffer.from(`{${marker.replace(/[._]|usage/, "x")}:1}`))); - } - assert.isFalse(gates[kind](Buffer.alloc(0))); - } - }); - it("matches markers beside multi-byte and malformed UTF-8", () => { const invalid = Buffer.from([0xff, 0xc3]); for (const kind of ["one", "several"] as const) { diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index d3f19afd20a9..1131b95bc7e9 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -45,6 +45,8 @@ export interface TranscriptParseResult { * segment: the next scan re-reads it once the writer finishes the line. */ readonly tailRecords: readonly UsageRecord[]; + /** Includes tentative tail diagnostics; position.state only includes committed lines. */ + readonly malformedRecords: number; readonly position: TranscriptParsePosition; readonly resumed: boolean; } @@ -306,10 +308,9 @@ export async function readTranscriptRecords( // `resumeOffset` and the next scan replays the tail from it. const encodedState = encodeState === undefined ? null : encodeState(state); const tailRecords: UsageRecord[] = []; - finish( - decodeState === undefined ? state : Option.getOrThrow(decodeState(encodedState)), - tailRecords, - ); + const tailState = + decodeState === undefined ? state : Option.getOrThrow(decodeState(encodedState)); + finish(tailState, tailRecords); const guardLength = Math.min(GUARD_LENGTH, resumeOffset); let guardHash = 0; @@ -322,6 +323,7 @@ export async function readTranscriptRecords( return { records, tailRecords, + malformedRecords: format.readMalformedRecords?.(tailState) ?? 0, position: { resumeOffset, guardLength, diff --git a/apps/server/src/usage/usageTranscriptStreaming.test.ts b/apps/server/src/usage/usageTranscriptStreaming.test.ts index e088c8bb2092..8dda52d1e8ef 100644 --- a/apps/server/src/usage/usageTranscriptStreaming.test.ts +++ b/apps/server/src/usage/usageTranscriptStreaming.test.ts @@ -50,8 +50,8 @@ const claude = (id = "m1", output = 99) => ({ }, }); const codex = [ - { type: "session_meta", timestamp, payload: { id: "s1" } }, - { type: "turn_context", timestamp, payload: { model: "gpt-5.6-sol" } }, + { type: "session_meta", timestamp, payload: { id: "s1", model_provider: "openai" } }, + { type: "turn_context", timestamp, payload: { model: "gpt-5.6-sol", turn_id: "initial-turn" } }, { type: "event_msg", timestamp, @@ -252,9 +252,14 @@ describe("large usage records", () => { it("preserves Codex model switches and duplicate suppression across streaming resumes", async () => { const path = NodePath.join(dir, "history.jsonl"); const first = await scan( - codex.map((record) => ({ padding: content, ...record })), + codex.map((record) => ({ padding: "x".repeat(9 * 1024 * 1024), ...record })), "codex", ); + expect(first.records[0]).toMatchObject({ + sessionId: "s1", + model: "gpt-5.6-sol", + unresolvedServiceTierTurnId: "initial-turn", + }); await NodeFSP.appendFile( path, [ @@ -281,6 +286,7 @@ describe("large usage records", () => { sessionId: "s1", totals: { outputTokens: 101 }, }); + expect(result?.records[0]).not.toHaveProperty("unresolvedServiceTierTurnId"); const full = await readTranscriptRecords(path, "codex"); expect([...first.records, ...result!.records]).toEqual(full?.records); }); diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 3c2b62259bff..3a36389ffb70 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -9,12 +9,14 @@ import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; -import { ChildProcessSpawner } from "effect/process"; import { assert, it } from "@effect/vitest"; import { CheckpointRef, GitCommandError, VcsProcessExitError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as ServerConfig from "../config.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -96,6 +98,37 @@ runVcsDriverContractSuite({ }, }); +it.effect("lists the fetch and push URLs of a partial clone remote", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-partial-clone-remotes-", + }); + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + yield* runGit(cwd, ["init"]); + yield* runGit(cwd, ["remote", "add", "origin", "https://github.com/pingdotgg/t3code.git"]); + yield* runGit(cwd, [ + "remote", + "set-url", + "--push", + "origin", + "git@github.com:octocat/t3code.git", + ]); + yield* runGit(cwd, ["config", "remote.origin.promisor", "true"]); + yield* runGit(cwd, ["config", "remote.origin.partialclonefilter", "blob:none"]); + + const result = yield* driver.listRemotes(cwd); + assert.deepEqual(result.remotes, [ + { + name: "origin", + url: "https://github.com/pingdotgg/t3code.git", + pushUrl: Option.some("git@github.com:octocat/t3code.git"), + isPrimary: true, + }, + ]); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + const makeCheckpointFixture = Effect.fn("makeCheckpointFixture")(function* ( driver: Effect.Success>, cwd: string, @@ -163,6 +196,54 @@ it.effect("checkpoint capture skips untracked nested repositories without a comm }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); +it.effect.skipIf(!symlinksSupported)( + "checkpoint capture writes its index into the real git dir when cwd is a symlinked subdirectory", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveRunner = yield* ProcessRunner.ProcessRunner; + const indexFiles: string[] = []; + const captureProcess = yield* VcsProcess.make.pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => { + const indexFile = input.env?.GIT_INDEX_FILE; + if (indexFile !== undefined) indexFiles.push(indexFile); + return liveRunner.run(input); + }, + }), + ); + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, captureProcess), + ); + const sandbox = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-checkpoint-symlink-", + }); + const repo = path.join(sandbox, "repo"); + const subdir = path.join(repo, "sub", "dir"); + const link = path.join(sandbox, "nest", "link"); + yield* fileSystem.makeDirectory(subdir, { recursive: true }); + yield* fileSystem.makeDirectory(path.dirname(link), { recursive: true }); + yield* fileSystem.symlink(subdir, link); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, repo); + // Git returns ../../.git from the target; resolving it against the link reaches the decoy. + yield* git(["-C", sandbox, "init"]); + yield* fileSystem.writeFileString(path.join(subdir, "nested.txt"), "from-link\n"); + const realCommonDir = yield* fileSystem.realPath(path.join(repo, ".git")); + + yield* driver.checkpoints.captureCheckpoint({ cwd: link, checkpointRef }); + + assert.isTrue(indexFiles.length > 0); + for (const indexFile of indexFiles) { + assert.strictEqual(yield* fileSystem.realPath(path.dirname(indexFile)), realCommonDir); + } + assert.strictEqual( + (yield* git(["show", `${checkpointRef}:sub/dir/nested.txt`])).stdout, + "from-link\n", + ); + }).pipe(Effect.scoped, Effect.provide(layerGitCaptureContract)), +); + it.effect("checkpoint capture keeps the shared index a split index still uses", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -231,6 +312,9 @@ it.effect("checkpoint capture still fails when a clean filter rejects a file", ( ); assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.strictEqual(result.failure._tag, "VcsProcessExitError"); + } assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); }).pipe(Effect.scoped, Effect.provide(layerGitContract)), @@ -266,47 +350,98 @@ it.effect("checkpoint capture refuses a truncated nested repository listing", () }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); -it.effect("checkpoint recovery refuses excessive candidates before probing", () => +it.effect("checkpoint capture skips an ignored project while keeping its allowed sibling", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const liveProcess = yield* VcsProcess.VcsProcess; const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-recovery-cap-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); - yield* git(["init", "empty0"]); - for (let i = 1; i < 65; i++) - yield* fs.copy(path.join(cwd, "empty0"), path.join(cwd, `empty${i}`)); - const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); - let stageError: VcsProcessExitError | undefined; - let nestedProbes = 0; - let stageAttempts = 0; - const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( - Effect.provideService(VcsProcess.VcsProcess, { - run: (input) => { - if (input.cwd !== cwd && input.args.includes("rev-parse")) nestedProbes++; - if (input.args.includes("add") && input.args.includes("-A")) stageAttempts++; - return liveProcess.run(input).pipe( - Effect.tapError((error) => { - if (error._tag === "VcsProcessExitError") stageError = error; - return Effect.void; - }), - ); - }, - }), + const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-ignored-" }); + const { git } = yield* makeCheckpointFixture(driver, parent); + yield* fs.writeFileString( + path.join(parent, ".gitignore"), + "*\n!.gitignore\n!file.txt\n!keep/\n!keep/**\n", ); - const result = yield* Effect.result( - captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }), + const ignoredCwd = path.join(parent, "ignored"); + const keepCwd = path.join(parent, "keep"); + yield* fs.makeDirectory(ignoredCwd); + yield* fs.makeDirectory(keepCwd); + yield* fs.writeFileString(path.join(ignoredCwd, "secret.txt"), "private\n"); + yield* fs.writeFileString(path.join(keepCwd, "new.txt"), "new\n"); + const originalIndex = yield* fs.readFile(path.join(parent, ".git", "index")); + const ignoredRef = CheckpointRef.make("refs/t3/checkpoints/ignored"); + const keepRef = CheckpointRef.make("refs/t3/checkpoints/keep"); + + const ignored = yield* Effect.result( + driver.checkpoints.captureCheckpoint({ cwd: ignoredCwd, checkpointRef: ignoredRef }), ); - assert.strictEqual(nestedProbes, 0); - assert.strictEqual(stageAttempts, 1); - assert.strictEqual(result._tag, "Failure"); - if (result._tag === "Failure") assert.strictEqual(result.failure, stageError); - assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); - assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); + yield* driver.checkpoints.captureCheckpoint({ cwd: keepCwd, checkpointRef: keepRef }); + + assert.strictEqual((yield* git(["show", `${keepRef}:keep/new.txt`])).stdout, "new\n"); + assert.strictEqual((yield* git(["ls-tree", "-r", keepRef, "--", "ignored"])).stdout, ""); + assert.isFalse( + yield* driver.checkpoints.hasCheckpointRef({ cwd: parent, checkpointRef: ignoredRef }), + ); + assert.deepEqual(yield* fs.readFile(path.join(parent, ".git", "index")), originalIndex); + assert.strictEqual(ignored._tag, "Failure"); + if (ignored._tag === "Failure") { + assert.strictEqual(ignored.failure._tag, "VcsUnsupportedOperationError"); + } }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); +it.effect.each([ + { tracking: "committed-only", committed: ["committed.txt"], staged: [] }, + { tracking: "staged-only", committed: [], staged: ["staged.txt"] }, + { tracking: "mixed", committed: ["committed.txt"], staged: ["staged.txt"] }, +])( + "checkpoint capture keeps $tracking tracked edits but excludes new files in a project ignored later", + ({ committed, staged }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-ignored-later-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, parent); + const cwd = path.join(parent, "sub"); + yield* fs.makeDirectory(cwd); + for (const file of committed) { + yield* fs.writeFileString(path.join(cwd, file), "committed\n"); + yield* git(["add", `sub/${file}`]); + } + if (committed.length > 0) { + yield* git(["commit", "-m", "track project"]); + } + for (const file of staged) { + yield* fs.writeFileString(path.join(cwd, file), "staged\n"); + yield* git(["add", `sub/${file}`]); + } + const tracked = [...committed, ...staged]; + yield* fs.writeFileString(path.join(parent, ".gitignore"), "sub/\n"); + for (const file of tracked) { + yield* fs.writeFileString(path.join(cwd, file), `${file} working\n`); + } + yield* fs.writeFileString(path.join(cwd, "secret.txt"), "private\n"); + const originalIndex = yield* fs.readFile(path.join(parent, ".git", "index")); + + const captured = yield* Effect.result( + driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }), + ); + + assert.deepEqual(yield* fs.readFile(path.join(parent, ".git", "index")), originalIndex); + if (captured._tag === "Failure") return yield* captured.failure; + assert.strictEqual( + (yield* git(["ls-tree", "-r", "--name-only", checkpointRef, "--", "sub"])).stdout, + tracked.map((file) => `sub/${file}\n`).join(""), + ); + for (const file of tracked) { + assert.strictEqual( + (yield* git(["show", `${checkpointRef}:sub/${file}`])).stdout, + `${file} working\n`, + ); + } + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + it.effect.each([ { phase: "add", nestedRecovery: false, expireRecovery: false }, { phase: "update-ref", nestedRecovery: false, expireRecovery: false }, @@ -413,95 +548,6 @@ it.effect.each([ }).pipe(Effect.scoped, Effect.provide(layerGitCaptureContract)), ); -it.effect.each(["discovery", "probe", "retry"] as const)( - "checkpoint recovery has one deadline including %s", - (blockedPhase) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const liveProcess = yield* VcsProcess.VcsProcess; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-recovery-timeout-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); - yield* git(["init", "empty"]); - const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); - const entered = yield* Deferred.make(); - const discovered = yield* Deferred.make(); - let stageError: VcsProcessExitError | undefined; - let privateIndex: string | undefined; - let interrupted = false; - let stagingAttempts = 0; - const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( - Effect.provideService(VcsProcess.VcsProcess, { - run: (input) => { - const staging = input.args.includes("add") && input.args.includes("-A"); - if (staging) { - privateIndex = input.env?.GIT_INDEX_FILE; - stagingAttempts += 1; - } - const block = - (blockedPhase === "discovery" && input.args.includes("--others")) || - (blockedPhase === "probe" && input.cwd !== cwd && input.args.includes("rev-parse")) || - (blockedPhase === "retry" && - staging && - input.args.some((arg) => arg.startsWith(":(exclude,literal)"))); - if (block) - return ( - blockedPhase === "retry" - ? fs - .writeFileString( - `${input.env!.GIT_INDEX_FILE!}.lock`, - "interrupted index write", - ) - .pipe(Effect.orDie) - : Effect.void - ).pipe( - Effect.andThen(Deferred.succeed(entered, undefined)), - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Effect.sync(() => { - interrupted = true; - }), - ), - ); - return liveProcess.run(input).pipe( - Effect.tap(() => - blockedPhase === "probe" && input.args.includes("--others") - ? Deferred.succeed(discovered, undefined).pipe( - Effect.andThen(Effect.sleep("3 seconds")), - ) - : Effect.void, - ), - Effect.tapError((error) => { - if (staging && error._tag === "VcsProcessExitError") stageError = error; - return Effect.void; - }), - ); - }, - }), - ); - const captureStore = yield* makeCaptureStore(captureDriver, cwd); - const fiber = yield* captureStore - .captureCheckpoint({ cwd, checkpointRef }) - .pipe(Effect.flip, Effect.forkScoped); - if (blockedPhase === "probe") { - yield* Deferred.await(discovered); - yield* TestClock.adjust("3 seconds"); - } - yield* Deferred.await(entered); - yield* TestClock.adjust(blockedPhase === "probe" ? "2 seconds" : "5 seconds"); - const error = yield* Fiber.join(fiber); - assert.strictEqual(error, stageError); - assert.strictEqual(stagingAttempts, blockedPhase === "retry" ? 2 : 1); - assert.isTrue(interrupted); - assert.isDefined(privateIndex); - assert.isFalse(yield* fs.exists(privateIndex!)); - assert.isFalse(yield* fs.exists(`${privateIndex!}.lock`)); - assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); - assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); - }).pipe(Effect.scoped, Effect.provide(layerGitContract)), -); - it.effect("checkpoint recovery preserves interruption and removes the private index", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -536,39 +582,6 @@ it.effect("checkpoint recovery preserves interruption and removes the private in }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); -it.effect("checkpoint capture does not rerun clean filters for unchanged indexed files", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-cache-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); - yield* fileSystem.writeFileString( - path.join(cwd, ".gitattributes"), - "stable.txt filter=probe\n", - ); - yield* fileSystem.writeFileString(path.join(cwd, "stable.txt"), "unchanged\n"); - yield* fileSystem.writeFileString( - path.join(cwd, ".git", "filter.cjs"), - 'require("node:fs").appendFileSync(".git/filter-runs", "read\\n"); process.stdin.pipe(process.stdout);', - ); - yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); - yield* fileSystem.utimes(path.join(cwd, "stable.txt"), 1_700_000_000, 1_700_000_000); - yield* git(["add", "."]); - yield* git(["commit", "-m", "record stable file"]); - yield* fileSystem.writeFileString(path.join(cwd, ".git", "filter-runs"), ""); - yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "changed\n"); - const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); - - yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); - - assert.strictEqual(yield* fileSystem.readFileString(path.join(cwd, ".git", "filter-runs")), ""); - assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "changed\n"); - assert.strictEqual((yield* git(["show", `${checkpointRef}:stable.txt`])).stdout, "unchanged\n"); - assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); - }).pipe(Effect.scoped, Effect.provide(layerGitContract)), -); - it.effect.each( [false, true].flatMap((nested) => (["sparse", "flags", "manual-skip", "missing", "non-cone-missing"] as const).map( @@ -667,46 +680,6 @@ it.effect.each( }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); -it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const liveProcess = yield* VcsProcess.VcsProcess; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-legacy-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); - yield* git(["sparse-checkout", "set", "--cone", "included"]); - const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); - const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( - Effect.provideService(VcsProcess.VcsProcess, { - run: (input) => { - if (input.args.includes("-h")) - return Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(129), - stdout: "usage: git add", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - return liveProcess.run( - input.args.includes("--sparse") - ? { - ...input, - args: input.args.map((arg) => - arg === "--sparse" ? "--unsupported-sparse" : arg, - ), - } - : input, - ); - }, - }), - ); - yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); - assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); - assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); - }).pipe(Effect.scoped, Effect.provide(layerGitContract)), -); - it.effect.each(["normal", "flags", "sparse"] as const)( "checkpoint index inspection handles entries beyond the output cap (index=%s)", (indexMode) => @@ -790,103 +763,6 @@ it.effect.each([1_700_000_000, 1_700_000_000.9999])( assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); -it.effect.each( - [false, true].flatMap((nested) => - (["sparse", "flags", "manual-skip", "missing", "non-cone-missing"] as const).map( - (indexState) => ({ nested, indexState }), - ), - ), -)( - "sparse checkpoint preserves two captures (nested=$nested, index=$indexState)", - ({ nested, indexState }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); - const { git } = yield* makeCheckpointFixture(driver, cwd); - const write = Effect.fn(function* (name: string, contents: string) { - yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); - yield* fs.writeFileString(path.join(cwd, name), contents); - }); - for (const name of [ - "scope/in/edit", - "scope/in/delete", - "scope/out/deep/absent", - "scope/out/present", - "elsewhere/file", - ]) { - yield* write(name, "original\n"); - } - yield* git(["add", "."]); - yield* git(["commit", "-m", "sparse fixture"]); - yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "scope/in", "elsewhere"]); - if (indexState === "non-cone-missing") - yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]); - yield* write("scope/in/edit", "staged\n"); - yield* write("elsewhere/file", "staged outside\n"); - yield* git(["add", "."]); - if (indexState === "flags") - yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]); - if (indexState === "manual-skip") - yield* git(["update-index", "--skip-worktree", "scope/in/delete"]); - yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]); - yield* write("scope/in/edit", "working\n"); - yield* write("scope/out/present", "modified skipped\n"); - yield* write("scope/out/new file", "new outside cone\n"); - yield* write("elsewhere/file", "working outside\n"); - yield* fs.remove(path.join(cwd, "scope/in/delete")); - const indexPath = path.join(cwd, ".git/index"); - if (indexState.endsWith("missing")) yield* fs.remove(indexPath); - const originalIndex = yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)); - const captureCwd = nested ? path.join(cwd, "scope") : cwd; - for (const turn of [1, 2]) { - const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`); - if (turn === 2) { - yield* write("scope/in/edit", "second\n"); - yield* fs.remove(path.join(cwd, "scope/out/new file")); - yield* write("scope/out/second", "second addition\n"); - } - const capture = driver.checkpoints.captureCheckpoint({ - cwd: captureCwd, - checkpointRef: ref, - }); - if (indexState === "non-cone-missing") { - assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError"); - assert.isFalse( - yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }), - ); - assert.isFalse(yield* fs.exists(indexPath)); - break; - } - yield* capture; - for (const [name, content] of [ - ["scope/out/deep/absent", "original\n"], - ["scope/out/present", "modified skipped\n"], - ["scope/in/edit", turn === 1 ? "working\n" : "second\n"], - ["elsewhere/file", nested ? "original\n" : "working outside\n"], - [ - turn === 1 ? "scope/out/new file" : "scope/out/second", - turn === 1 ? "new outside cone\n" : "second addition\n", - ], - ]) { - assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content); - } - const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0"); - assert.notInclude(files, "scope/in/delete"); - if (turn === 2) assert.notInclude(files, "scope/out/new file"); - assert.deepEqual( - yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)), - originalIndex, - ); - assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent"))); - assert.strictEqual( - yield* fs.readFileString(path.join(cwd, "elsewhere/file")), - "working outside\n", - ); - } - }).pipe(Effect.scoped, Effect.provide(layerGitContract)), -); it.effect("checkpoint capture preserves racy edits made after resetting the index", () => Effect.gen(function* () { @@ -1101,114 +977,43 @@ it.effect("restores empty checkpoints without changing paths outside the workspa }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); -it.effect("GitVcsDriver forwards execute env to the VCS process", () => { - let observedEnv: NodeJS.ProcessEnv | undefined; - let observedAppendTruncationMarker: boolean | undefined; - let observedOutputMode: VcsProcess.VcsProcessInput["outputMode"]; - - return Effect.gen(function* () { - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - - yield* driver.execute({ - operation: "GitVcsDriver.test.env", - cwd: "/repo", - args: ["status"], - env: { - GIT_INDEX_FILE: "/tmp/t3-index", - }, - appendTruncationMarker: true, - outputMode: "error", - }); - - assert.deepStrictEqual(observedEnv, { - GIT_INDEX_FILE: "/tmp/t3-index", - }); - assert.strictEqual(observedAppendTruncationMarker, true); - assert.strictEqual(observedOutputMode, "error"); - }).pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => - Effect.sync(() => { - observedEnv = input.env; - observedAppendTruncationMarker = input.appendTruncationMarker; - observedOutputMode = input.outputMode; - return { - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }; - }), - }), - ), +// The real VCS process, spawning `command` wherever the driver runs git. +const vcsProcessSpawning = (command: string) => + Layer.effect( + VcsProcess.VcsProcess, + Effect.map(VcsProcess.make, (vcsProcess) => + VcsProcess.VcsProcess.of({ run: (input) => vcsProcess.run({ ...input, command }) }), ), - ); -}); + ).pipe(Layer.provide(ProcessRunner.layer), Layer.provideMerge(NodeServices.layer)); -it.effect("GitVcsDriver flushes checkpoint objects and refs to disk before publishing them", () => { - const observedArgs: ReadonlyArray[] = []; +it.effect("GitVcsDriver treats a missing git executable as outside any repository", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-missing-git-" }); + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provide(vcsProcessSpawning(path.join(cwd, "git"))), + ); - return Effect.gen(function* () { - const driver = yield* GitVcsDriver.makeVcsDriverShape(); + assert.strictEqual(yield* driver.isInsideWorkTree(cwd), false); + assert.strictEqual(yield* driver.detectRepository(cwd), null); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); - yield* driver.checkpoints.captureCheckpoint({ - cwd: "/repo", - checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread/turn/1"), - }); +it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "GitVcsDriver still fails detection when git exists but cannot be spawned", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + // A file without the execute bit fails to spawn with a permission error. + const notExecutable = yield* fileSystem.makeTempFileScoped({ prefix: "t3-git-" }); + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provide(vcsProcessSpawning(notExecutable)), + ); - const writeCommands = ["add", "write-tree", "commit-tree", "update-ref"]; - const writes = observedArgs.filter((args) => - writeCommands.some((command) => args.includes(command)), - ); - assert.strictEqual(writes.length, 4); - for (const args of writes) { - const command = args.findIndex((arg) => writeCommands.includes(arg)); - for (const setting of ["core.fsync=objects,reference", "core.fsyncMethod=fsync"]) { - const index = args.indexOf(setting); - assert.strictEqual(args[index - 1], "-c", args.join(" ")); - assert.isBelow(index, command); - } - } - assert.deepStrictEqual(observedArgs.at(-1), [ - "-C", - "/repo", - "-c", - "core.fsync=objects,reference", - "-c", - "core.fsyncMethod=fsync", - "update-ref", - "refs/t3/checkpoints/thread/turn/1", - "commit0000", - ]); - }).pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => - Effect.sync(() => { - observedArgs.push(input.args); - const stdout = input.args.includes("write-tree") - ? "tree0000\n" - : input.args.includes("commit-tree") - ? "commit0000\n" - : input.args.includes("--git-common-dir") - ? ".git\n" - : ""; - return { - exitCode: ChildProcessSpawner.ExitCode(0), - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }; - }), - }), - ), - ), - ); -}); + const error = yield* driver.isInsideWorkTree(process.cwd()).pipe(Effect.flip); + assert.strictEqual(error._tag, "VcsProcessSpawnError"); + const detectionError = yield* driver.detectRepository(process.cwd()).pipe(Effect.flip); + assert.strictEqual(detectionError._tag, "VcsProcessSpawnError"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index e3105b5c6d05..684285d608d5 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -13,6 +13,7 @@ import { ChildProcessSpawner } from "effect/process"; import { GitCommandError, VcsProcessExitError, + VcsUnsupportedOperationError, type VcsSwitchRefInput, type VcsSwitchRefResult, type VcsCreateRefInput, @@ -32,6 +33,8 @@ import { type VcsStatusResult, type WorktreeSubmodules, } from "@t3tools/contracts"; +import { isMissingExecutable } from "@t3tools/source-control-core/server/discovery"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { makeGitVcsDriverCore, PATCH_RENDER_PREFIX_ARGS, @@ -48,6 +51,7 @@ export interface ExecuteGitInput { readonly env?: NodeJS.ProcessEnv; readonly allowNonZeroExit?: boolean; readonly timeoutMs?: number | null; + readonly forceKillAfterMs?: number; readonly maxOutputBytes?: number; readonly appendTruncationMarker?: boolean; /** @@ -71,7 +75,6 @@ export interface GitStatusDetails { isRepo: boolean; sourceControlProvider?: VcsStatusResult["sourceControlProvider"]; hasOriginRemote: boolean; - remoteNames?: ReadonlyArray; isDefaultBranch: boolean; branch: string | null; upstreamRef: string | null; @@ -257,8 +260,6 @@ export interface GitFetchRemoteInput { cwd: string; remoteName: string; refName?: string; - /** Fail when `refName` is missing on the remote instead of fetching the whole remote. */ - requireBranch?: boolean; } export interface GitRemoteExistsInput { @@ -281,6 +282,11 @@ export interface GitResolveRemoteTrackingCommitResult { remoteRefName: string; } +export interface GitFetchWorktreeBaseCommitInput { + cwd: string; + refName: string; +} + export interface GitSetBranchUpstreamInput { cwd: string; branch: string; @@ -374,6 +380,13 @@ export class GitVcsDriver extends Context.Service< readonly resolveRemoteTrackingCommit: ( input: GitResolveRemoteTrackingCommitInput, ) => Effect.Effect; + readonly resolveWorktreeBaseRemote: ( + input: GitFetchWorktreeBaseCommitInput, + ) => Effect.Effect<{ remoteName: string; branchName: string } | null, GitCommandError>; + /** Fetches the configured upstream, explicit remote ref, or origin; null uses the local base. */ + readonly fetchWorktreeBaseCommit: ( + input: GitFetchWorktreeBaseCommitInput, + ) => Effect.Effect; readonly fetchRemoteBranch: ( input: GitFetchRemoteBranchInput, ) => Effect.Effect; @@ -411,7 +424,6 @@ const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const CHECKPOINT_RECOVERY_MAX_CANDIDATES = 64; const CHECKPOINT_RECOVERY_TIMEOUT = "5 seconds"; const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; -const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000; const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ "-c", "core.fsmonitor=false", @@ -468,7 +480,8 @@ function parseGitRemoteVerboseOutput( continue; } - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + // Partial clones append the promisor filter, e.g. `(fetch) [blob:none]`. + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(trimmed); if (!match) { continue; } @@ -529,6 +542,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const path = yield* Path.Path; const vcsProcess = yield* VcsProcess.VcsProcess; const crypto = yield* Crypto.Crypto; + const hostPlatform = yield* HostProcess.Platform; const capabilities = { kind: "git" as const, supportsWorktrees: true, @@ -549,7 +563,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( timeoutMs: 5_000, maxOutputBytes: 4_096, }, - ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); + ).pipe( + Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true"), + Effect.catchIf(isMissingExecutable, () => Effect.succeed(false)), + ); const execute: VcsDriver.VcsDriver["Service"]["execute"] = (input) => gitCommand(vcsProcess, input.operation, input.cwd, input.args, { @@ -772,29 +789,43 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }), ); - const resolveGitCommonDir = (cwd: string) => - Effect.gen(function* () { - const result = yield* execute({ - operation: "GitVcsDriver.checkpoints.resolveGitCommonDir", - cwd, - args: ["rev-parse", "--git-common-dir"], - }); - const gitCommonDir = result.stdout.trim(); - return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); + // Git reports relative paths from the physical cwd, which can differ from a symlink. + const resolveGitCommonDir = Effect.fn("GitVcsDriver.resolveGitCommonDir")(function* ( + cwd: string, + ) { + const result = yield* execute({ + operation: "GitVcsDriver.checkpoints.resolveGitCommonDir", + cwd, + args: ["rev-parse", "--git-common-dir"], }); + const gitCommonDir = result.stdout.trim(); + if (path.isAbsolute(gitCommonDir)) return gitCommonDir; + const physicalCwd = yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd)); + return path.resolve(physicalCwd, gitCommonDir); + }); // Git renames loose objects and refs into place without fsync by default, so // an unclean restart can leave 0-byte files under refs/t3/** that break every // later fetch and push. Checkpoint writes flush before they are published; // macOS defaults to writeout-only, which does not reach the disk either. + // Set fsync first: Git 2.36 ignores unknown batch without resetting the method. + // Git documents batch/fsync equivalence on macOS HFS+/APFS and Windows NTFS/ReFS; + // keep per-object fsync on other platforms. Batch still fully flushes refs. // On macOS, VcsProcess retries unsupported full flushes with plain fsync. const durableWrite = [ "-c", "core.fsync=objects,reference", "-c", "core.fsyncMethod=fsync", + ...(hostPlatform === "darwin" || hostPlatform === "win32" + ? ["-c", "core.fsyncMethod=batch"] + : []), ] as const; + // Checkpoints must handle existing paths beyond Windows MAX_PATH, even when + // core.longpaths is disabled in the user's Git configuration. + const longPaths = hostPlatform === "win32" ? ["-c", "core.longpaths=true"] : []; + const checkpoints: VcsDriver.VcsCheckpointOps = { captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { const operation = VcsProcess.CHECKPOINT_CAPTURE_OPERATION; @@ -950,6 +981,64 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } } + const workspaceIgnored = (yield* filterIgnoredPaths(input.cwd, ["."])).length === 0; + if (workspaceIgnored) { + // HEAD omits staged-only paths. Seed just this workspace's additions, keeping + // unrelated staged changes and the scratch index's sparse flags untouched. + const additions = yield* execute({ + operation, + cwd: input.cwd, + args: [ + "-c", + "core.quotePath=true", + "diff", + "--cached", + "--raw", + "--no-abbrev", + "--no-renames", + "--no-relative", + "--ignore-submodules=none", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--ita-visible-in-index", + "--diff-filter=A", + "--", + ".", + ], + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + outputMode: "error", + }); + if (additions.stdout.length > 0) { + // Git's quoted paths round-trip through index-info, including non-UTF-8 names. + yield* execute({ + operation, + cwd: input.cwd, + args: [...indexConfig, "-c", "index.sparse=false", "update-index", "--index-info"], + stdin: additions.stdout.replace( + /^:000000 ([0-7]{6}) 0+ ([0-9a-f]+) A\t/gm, + "$1 $2\t", + ), + env: commitEnv, + }); + } + const tracked = yield* execute({ + operation, + cwd: input.cwd, + args: [...indexConfig, "ls-files", "--cached", "-z", "--", "."], + env: commitEnv, + maxOutputBytes: 1, + outputMode: "truncate", + }); + if (tracked.stdout.length === 0) { + return yield* new VcsUnsupportedOperationError({ + operation, + kind: "git", + detail: "The workspace directory is ignored by its Git repository.", + }); + } + } + const stageFiles = (exclusions: ReadonlyArray) => execute({ operation, @@ -957,9 +1046,11 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( args: [ ...indexConfig, ...durableWrite, + ...longPaths, "add", ...(sparseCheckout ? ["--sparse"] : []), - "-A", + // add -A rejects ignored directories even when they contain tracked files. + workspaceIgnored ? "-u" : "-A", "--", ".", ...exclusions, @@ -975,7 +1066,15 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const untracked = yield* execute({ operation, cwd: input.cwd, - args: ["ls-files", "--others", "--exclude-standard", "-z", "--", "."], + args: [ + ...longPaths, + "ls-files", + "--others", + "--exclude-standard", + "-z", + "--", + ".", + ], env: commitEnv, maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, }); @@ -1095,7 +1194,16 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( yield* execute({ operation, cwd: input.cwd, - args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], + args: [ + ...longPaths, + "restore", + "--source", + commitOid, + "--worktree", + "--staged", + "--", + ".", + ], }); } // Restoring away the last tracked file can remove a nested workspace directory. @@ -1115,7 +1223,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const cleaned = yield* execute({ operation, cwd: input.cwd, - args: ["clean", "-fd", "--", "."], + args: [...longPaths, "clean", "-fd", "--", "."], allowNonZeroExit: true, }); if (cleaned.exitCode !== 0) { @@ -1144,7 +1252,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( yield* execute({ operation, cwd: input.cwd, - args: ["reset", "--quiet", "--", "."], + args: [...longPaths, "reset", "--quiet", "--", "."], }); } @@ -1205,7 +1313,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( : []), ], allowNonZeroExit: true, - maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, + maxOutputBytes: input.maxOutputBytes ?? VcsDriver.CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, outputMode: input.format === "numstat" ? "error" : "truncate", }); @@ -1247,7 +1355,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( operation, cwd: input.cwd, args: [...args, "--name-only", "-z", "--no-renames", "--no-ext-diff"], - maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, + maxOutputBytes: VcsDriver.CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, outputMode: "error", }).pipe(Effect.map((result) => result.stdout.split("\0"))); // Remerge diff lists a merge's paths only where the result differs from Git's diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 3c64703d1930..3fc6aefc43e6 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -8,29 +8,27 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; -import * as Metric from "effect/Metric"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; -import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Scope from "effect/Scope"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { GitCommandError, ReviewDiffPreviewInput, + VcsRef, + VcsListRefsResult, type ReviewDiffFileContentsInput, type ReviewDiffPreviewResult, type WorktreeSubmodules, } from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as ServerConfig from "../config.ts"; -import { gitCommandDuration } from "../observability/Metrics.ts"; import { makeGitVcsDriverCore, parseGitCheckoutProgressLine, @@ -41,6 +39,8 @@ import * as GitVcsDriver from "./GitVcsDriver.ts"; import { gitConfigEnv } from "./VcsProcess.ts"; const encodeGitCommandError = Schema.encodeEffect(Schema.fromJsonString(GitCommandError)); +const decodeVcsRef = Schema.decodeUnknownSync(VcsRef); +const decodeVcsListRefsResult = Schema.decodeUnknownSync(VcsListRefsResult); const layerServerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-vcs-driver-test-", @@ -80,6 +80,13 @@ const makeSuccessfulHandle = (stdout: string) => getOutputFd: () => Stream.empty, }); +const makeFailedHandle = (stderr: string) => + ChildProcessSpawner.makeHandle({ + ...makeNonRepositoryHandle(), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + stderr: Stream.encodeText(Stream.make(stderr)), + }); + const makeTmpDir = ( prefix = "git-vcs-driver-test-", ): Effect.Effect => @@ -151,112 +158,6 @@ const initRepoWithCommit = ( return { initialBranch }; }); -it.effect("bounds Git bursts across drivers without timing out queued commands", () => - Effect.gen(function* () { - const gate = yield* Deferred.make(); - const starts = yield* Queue.unbounded(); - let active = 0; - let peak = 0; - const spawner = ChildProcessSpawner.make(() => - Effect.acquireRelease( - Effect.gen(function* () { - peak = Math.max(peak, ++active); - yield* Queue.offer(starts, active); - return ChildProcessSpawner.makeHandle({ - ...makeSuccessfulHandle("ok"), - exitCode: Deferred.await(gate).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - () => Effect.sync(() => active--), - ), - ); - const drivers = yield* Effect.all( - Array.from({ length: 16 }, () => - makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - ), - ); - const burst = yield* Effect.forEach( - drivers, - (driver, index) => - driver.execute({ - operation: "test.gitBurst", - cwd: "/repo", - args: ["rev-parse", "HEAD"], - ...(index < 4 ? {} : { timeoutMs: index < 8 ? 30_000 : 1_000 }), - }), - { concurrency: "unbounded" }, - ).pipe(Effect.forkChild); - - yield* TestClock.adjust("2 seconds"); - assert.equal(yield* Queue.size(starts), 8); - assert.equal(peak, 8); - yield* Deferred.succeed(gate, undefined); - const results = yield* Fiber.join(burst); - assert.equal(results.length, 16); - assert.isTrue(results.every((result) => result.stdout === "ok" && result.exitCode === 0)); - assert.equal(peak, 8); - assert.equal(active, 0); - const duration = yield* Metric.value( - Metric.withAttributes(gitCommandDuration, [["operation", "test.gitBurst"]]), - ); - assert.equal(duration.count, 16); - assert.equal(duration.sum, 16_000); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), -); - -it.effect.each([{ timeoutMs: null }, { timeoutMs: 30_001 }])( - "keeps all Git slots available with a pending command whose timeout is $timeoutMs", - ({ timeoutMs }) => - Effect.gen(function* () { - const slowGate = yield* Deferred.make(); - const fastGate = yield* Deferred.make(); - const starts = yield* Queue.unbounded(); - let active = 0; - const spawner = ChildProcessSpawner.make((command) => - Effect.acquireRelease( - Effect.gen(function* () { - active++; - yield* Queue.offer(starts, undefined); - const gate = - ChildProcess.isStandardCommand(command) && command.args[0] === "push" - ? slowGate - : fastGate; - return ChildProcessSpawner.makeHandle({ - ...makeSuccessfulHandle("ok"), - exitCode: Deferred.await(gate).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - () => Effect.sync(() => active--), - ), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - const slow = yield* driver - .execute({ operation: "test.slowGit", cwd: "/repo", args: ["push"], timeoutMs }) - .pipe(Effect.forkChild); - yield* Queue.take(starts); - const burst = yield* Effect.all( - Array.from({ length: 8 }, () => - driver.execute({ operation: "test.fastGit", cwd: "/repo", args: ["status"] }), - ), - { concurrency: "unbounded" }, - ).pipe(Effect.forkChild); - - yield* TestClock.adjust("0 seconds"); - assert.equal(yield* Queue.size(starts), 8); - assert.equal(active, 9); - yield* Deferred.succeed(fastGate, undefined); - assert.equal((yield* Fiber.join(burst)).length, 8); - assert.equal(active, 1); - yield* Deferred.succeed(slowGate, undefined); - assert.equal((yield* Fiber.join(slow)).stdout, "ok"); - assert.equal(active, 0); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), -); - it.effect.each(["root", "nested", "worktree"] as const)( "skips clean filters while the %s index is locked and resumes after unlock", (location) => @@ -300,47 +201,7 @@ it.effect.each(["root", "nested", "worktree"] as const)( }).pipe(Effect.provide(layerTest)), ); -it.effect("uses stable diagnostics for every parsed non-repository command", () => { - const commands: Array<{ readonly args: ReadonlyArray; readonly lcAll?: string }> = []; - const spawner = ChildProcessSpawner.make((command) => - Effect.sync(() => { - if (!ChildProcess.isStandardCommand(command)) { - return assert.fail("expected a standard Git command"); - } - commands.push({ - args: command.args, - ...(command.options.env?.LC_ALL ? { lcAll: command.options.env.LC_ALL } : {}), - }); - return makeNonRepositoryHandle(); - }), - ); - const layerNodeServices = Layer.merge( - NodeServices.layer, - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - const layer = GitVcsDriver.layer.pipe( - Layer.provide(layerServerConfig), - Layer.provideMerge(layerNodeServices), - ); - - return Effect.gen(function* () { - const driver = yield* GitVcsDriver.GitVcsDriver; - const cwd = "/repo"; - - yield* driver.statusDetailsLocal(cwd); - yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); - yield* driver.listRefs({ cwd }); - - assert.deepStrictEqual(commands, [ - { args: ["rev-parse", "--git-path", "index"], lcAll: "C" }, - { args: ["status", "--porcelain=2", "--branch"], lcAll: "C" }, - { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" }, - { args: ["rev-parse", "--git-common-dir"], lcAll: "C" }, - ]); - }).pipe(Effect.provide(layer)); -}); - -it.effect("invalidates the remote names cache when a driver mutation adds a remote", () => +it.effect("invalidates origin remote cache when a driver mutation adds origin", () => Effect.gen(function* () { const driver = yield* GitVcsDriver.GitVcsDriver; const cwd = yield* makeTmpDir(); @@ -350,164 +211,21 @@ it.effect("invalidates the remote names cache when a driver mutation adds a remo const before = yield* driver.statusDetailsLocal(cwd); assert.equal(before.hasOriginRemote, false); - assert.deepEqual(before.remoteNames, []); - - const upstream = yield* makeTmpDir("git-vcs-driver-upstream-"); - yield* git(upstream, ["init", "--bare"]); - yield* driver.ensureRemote({ cwd, preferredName: "upstream", url: upstream }); - - const upstreamOnly = yield* driver.statusDetailsLocal(cwd); - assert.equal(upstreamOnly.hasOriginRemote, false); - assert.deepEqual(upstreamOnly.remoteNames, ["upstream"]); yield* driver.ensureRemote({ cwd, preferredName: "origin", url: remote }); const after = yield* driver.statusDetailsLocal(cwd); assert.equal(after.hasOriginRemote, true); - const forkStatus = yield* driver.status({ cwd }); - assert.equal(forkStatus.hasPrimaryRemote, true); - assert.deepEqual(forkStatus.remoteNames, ["origin", "upstream"]); }).pipe(Effect.provide(layerTest)), ); -it.effect("re-reads origin remote status after cache TTL expiry and bypassed invalidation", () => - Effect.gen(function* () { - const driver = yield* GitVcsDriver.GitVcsDriver; - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-vcs-driver-remote-"); - yield* initRepoWithCommit(cwd); - yield* git(remote, ["init", "--bare"]); - - assert.equal((yield* driver.statusDetailsLocal(cwd)).hasOriginRemote, false); - - yield* git(cwd, ["remote", "add", "origin", remote]); - - const stillCached = yield* driver.statusDetailsLocal(cwd); - assert.equal(stillCached.hasOriginRemote, false); - - yield* TestClock.adjust("6 minutes"); - - const afterExpiry = yield* driver.statusDetailsLocal(cwd); - assert.equal(afterExpiry.hasOriginRemote, true); - }).pipe(Effect.provide(layerTest)), -); - -it.effect("coalesces concurrent ref pages into one repository snapshot", () => - Effect.scoped( - Effect.gen(function* () { - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const spawnedArgs = yield* Ref.make>>([]); - const firstWorktreeScanStarted = yield* Deferred.make(); - const remoteNamesScanCompleted = yield* Deferred.make(); - const delayFirstWorktreeScan = yield* Ref.make(true); - const countingSpawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command)) { - return yield* Effect.die("expected a standard Git command"); - } - yield* Ref.update(spawnedArgs, (current) => [...current, command.args]); - const isWorktreeScan = - command.args.includes("worktree") && command.args.includes("--porcelain"); - const shouldDelay = - isWorktreeScan && (yield* Ref.getAndSet(delayFirstWorktreeScan, false)); - if (shouldDelay) { - yield* Deferred.succeed(firstWorktreeScanStarted, undefined); - yield* Effect.sleep("8 seconds"); - } - const handle = yield* delegate.spawn(command); - const isRemoteNamesScan = - command.args.length === 3 && - command.args[0] === "--git-dir" && - command.args[2] === "remote"; - return isRemoteNamesScan - ? ChildProcessSpawner.makeHandle({ - ...handle, - exitCode: handle.exitCode.pipe( - Effect.tap(() => Deferred.succeed(remoteNamesScanCompleted, undefined)), - ), - }) - : handle; - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, countingSpawner), - ); - const cwd = yield* makeTmpDir(); - const runGit = (args: ReadonlyArray) => - driver.execute({ - operation: "GitVcsDriver.test.coalescedListRefs", - cwd, - args, - timeoutMs: 10_000, - }); - - yield* driver.initRepo({ cwd }); - yield* runGit(["config", "user.email", "test@test.com"]); - yield* runGit(["config", "user.name", "Test"]); - yield* writeTextFile(cwd, "README.md", "# test\n"); - yield* runGit(["add", "."]); - yield* runGit(["commit", "-m", "initial commit"]); - yield* Ref.set(spawnedArgs, []); - - const initialRequest = yield* driver - .listRefs({ cwd, refresh: true, limit: 100 }) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(firstWorktreeScanStarted); - yield* Deferred.await(remoteNamesScanCompleted); - yield* TestClock.adjust("6 seconds"); - const laterRequests = yield* Effect.all( - Array.from({ length: 30 }, (_, index) => - driver.listRefs({ - cwd, - refresh: true, - query: `missing-${index}`, - limit: 100, - }), - ), - { concurrency: "unbounded" }, - ).pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust("2 seconds"); - yield* Fiber.join(initialRequest); - yield* Fiber.join(laterRequests); - yield* driver.listRefs({ cwd, cursor: 1, limit: 100 }); - - const firstSnapshotCommands = yield* Ref.get(spawnedArgs); - const snapshotRefScans = firstSnapshotCommands.filter( - (args) => - args.includes("for-each-ref") && - args.includes("refs/heads") && - args.includes("refs/remotes"), - ); - const worktreeScans = firstSnapshotCommands.filter( - (args) => args.includes("worktree") && args.includes("--porcelain"), - ); - assert.equal(snapshotRefScans.length, 1); - assert.equal(worktreeScans.length, 1); - - yield* driver.createRef({ cwd, refName: "feature/cache-invalidation" }); - const refreshed = yield* driver.listRefs({ cwd, limit: 100 }); - assert.equal( - refreshed.refs.some((ref) => ref.name === "feature/cache-invalidation"), - true, - ); - const allCommands = yield* Ref.get(spawnedArgs); - assert.equal( - allCommands.filter( - (args) => - args.includes("for-each-ref") && - args.includes("refs/heads") && - args.includes("refs/remotes"), - ).length, - 2, - ); - }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), -); - -describe("pull output beyond the output cap", () => { +describe("pullCurrentBranch", () => { // A fast-forward that prints over 1 MB takes thousands of files, so the real pull's output is // padded past the cap instead. - const withPaddedPull = (stream: "stdout" | "stderr") => + const withPaddedPull = ( + stream: "stdout" | "stderr", + onPull?: (args: ReadonlyArray) => void, + ) => Effect.gen(function* () { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const padding = new Uint8Array(1_000_001).fill(0x20); @@ -518,6 +236,7 @@ describe("pull output beyond the output cap", () => { ChildProcess.isStandardCommand(command) && command.args.includes("pull") && command.args.includes("--ff-only"); + if (isPull) onPull?.(command.args); return isPull ? ChildProcessSpawner.makeHandle({ ...handle, @@ -546,9 +265,122 @@ describe("pull output beyond the output cap", () => { yield* git(updater, ["add", "remote.txt"]); yield* git(updater, ["commit", "-m", "remote commit"]); yield* git(updater, ["push", "origin", initialBranch]); - return { cwd, remoteHead: yield* git(updater, ["rev-parse", "HEAD"]) }; + return { + cwd, + remote, + updater, + initialBranch, + remoteHead: yield* git(updater, ["rev-parse", "HEAD"]), + }; }); + it.effect("still fetches other branches on the first pull", () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const { cwd, updater, remoteHead } = yield* cloneBehindRemote; + yield* git(updater, ["push", "origin", "HEAD:refs/heads/other"]); + + const result = yield* driver.pullCurrentBranch(cwd); + + assert.equal(result.status, "pulled"); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), remoteHead); + assert.equal(yield* git(cwd, ["rev-parse", "refs/remotes/origin/other"]), remoteHead); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect.each([false, true])( + "pulls past an unrelated lock with custom fetch mapping=%s", + (customMapping) => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const fs = yield* FileSystem.FileSystem; + const { cwd, updater, initialBranch, remoteHead } = yield* cloneBehindRemote; + yield* git(updater, ["push", "origin", "HEAD:refs/heads/other"]); + const remoteName = customMapping ? "team/upstream" : "origin"; + const refPrefix = customMapping + ? `refs/remotes/${remoteName}/mapped` + : "refs/remotes/origin"; + if (customMapping) { + yield* git(cwd, ["remote", "rename", "origin", remoteName]); + yield* git(cwd, ["config", `remote.${remoteName}.fetch`, `+refs/heads/*:${refPrefix}/*`]); + yield* git(cwd, ["update-ref", `${refPrefix}/${initialBranch}`, "HEAD"]); + } + const lock = `.git/${refPrefix}/other.lock`; + yield* writeTextFile(cwd, lock, ""); + + const result = yield* driver.pullCurrentBranch(cwd); + + assert.equal(result.status, "pulled"); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), remoteHead); + assert.equal(yield* git(cwd, ["rev-parse", `${refPrefix}/${initialBranch}`]), remoteHead); + assert.isTrue(yield* fs.exists(`${cwd}/${lock}`)); + assert.equal(yield* fs.readFileString(`${cwd}/${lock}`), ""); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect("reports an upstream lock after the fallback without removing it", () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const fs = yield* FileSystem.FileSystem; + const { cwd, initialBranch } = yield* cloneBehindRemote; + const before = yield* git(cwd, ["rev-parse", "HEAD"]); + const lock = `.git/refs/remotes/origin/${initialBranch}.lock`; + yield* writeTextFile(cwd, lock, ""); + + const error = yield* Effect.flip(driver.pullCurrentBranch(cwd)); + + assert.include(error.detail, "could not update a local reference"); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), before); + assert.isTrue(yield* fs.exists(`${cwd}/${lock}`)); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect.each(["branch", "HEAD"] as const)( + "does not retry a failed fetch if another operation moved %s", + (moved) => + Effect.gen(function* () { + const fixtureDriver = yield* GitVcsDriver.GitVcsDriver; + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const { cwd, updater } = yield* cloneBehindRemote; + yield* git(updater, ["push", "origin", "HEAD:refs/heads/other"]); + yield* writeTextFile(cwd, ".git/refs/remotes/origin/other.lock", ""); + let pulls = 0; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const handle = yield* delegate.spawn(command); + if (!ChildProcess.isStandardCommand(command) || !command.args.includes("pull")) { + return handle; + } + pulls += 1; + return ChildProcessSpawner.makeHandle({ + ...handle, + exitCode: handle.exitCode.pipe( + Effect.tap(() => + git( + cwd, + moved === "branch" + ? ["checkout", "-b", "moved"] + : ["commit", "--allow-empty", "-m", "concurrent commit"], + ).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, fixtureDriver), + Effect.orDie, + ), + ), + ), + }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const error = yield* Effect.flip(driver.pullCurrentBranch(cwd)); + + assert.include(error.detail, "could not update a local reference"); + assert.equal(pulls, 1); + }).pipe(Effect.scoped, Effect.provide(layerServerConfig.pipe(Layer.provideMerge(layerTest)))), + ); + it.effect.each(["stdout", "stderr"] as const)( "reports a fast-forward whose %s passes the cap as pulled", (stream) => @@ -570,7 +402,8 @@ describe("pull output beyond the output cap", () => { it.effect("still fails a pull that Git rejects, whatever its output size", () => Effect.scoped( Effect.gen(function* () { - const driver = yield* withPaddedPull("stderr"); + const pulls: ReadonlyArray[] = []; + const driver = yield* withPaddedPull("stderr", (args) => pulls.push(args)); yield* Effect.gen(function* () { const { cwd } = yield* cloneBehindRemote; yield* writeTextFile(cwd, "local.txt", "local\n"); @@ -582,6 +415,7 @@ describe("pull output beyond the output cap", () => { assert.equal(error.operation, "GitVcsDriver.pullCurrentBranch.pull"); assert.equal(error.exitCode, 128); assert.notInclude(error.detail, "exceeded"); + assert.lengthOf(pulls, 1); }).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); }), ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), @@ -679,42 +513,6 @@ it.effect("invalidates a ref snapshot when a mutation fails after changing Git", ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), ); -it.effect("fails a ref snapshot when for-each-ref exits unsuccessfully", () => - Effect.scoped( - Effect.gen(function* () { - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const snapshotAttempts = yield* Ref.make(0); - const failingSnapshotSpawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command)) { - return yield* Effect.die("expected a standard Git command"); - } - if (command.args.includes("for-each-ref")) { - yield* Ref.update(snapshotAttempts, (count) => count + 1); - return makeNonRepositoryHandle(); - } - return yield* delegate.spawn(command); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingSnapshotSpawner), - ); - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); - - const error = yield* driver.listRefs({ cwd, refresh: true }).pipe(Effect.flip); - - assert.deepInclude(error, { - _tag: "GitCommandError", - operation: "GitVcsDriver.listRefs.snapshotRefs", - detail: "Git ref snapshot enumeration failed.", - exitCode: 128, - }); - assert.equal(yield* Ref.get(snapshotAttempts), 1); - }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), -); - it.effect("marks the current branch when worktree metadata is unavailable", () => Effect.scoped( Effect.gen(function* () { @@ -837,130 +635,26 @@ it.effect("reads worktrees from git that rejects worktree list -z", () => ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), ); -it.effect("refreshes the current branch after an external checkout", () => +it.effect("does not report a separate git dir as the worktree of its branch", () => Effect.scoped( Effect.gen(function* () { const driver = yield* GitVcsDriver.GitVcsDriver; const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - yield* git(cwd, ["branch", "external-checkout"]); + const gitDir = yield* makeTmpDir(); + yield* git(cwd, ["init", `--separate-git-dir=${gitDir}`]); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* git(cwd, ["commit", "--allow-empty", "-m", "init"]); - const initialRefs = yield* driver.listRefs({ cwd, refresh: true }); - assert.isTrue(initialRefs.refs.find((ref) => ref.name === initialBranch)?.current); - - yield* driver.execute({ - operation: "GitVcsDriver.test.externalCheckout", - cwd, - args: ["checkout", "external-checkout"], - timeoutMs: 10_000, - }); - yield* TestClock.adjust("6 seconds"); - - const refreshedRefs = yield* driver.listRefs({ cwd, refresh: true }); - assert.isTrue(refreshedRefs.refs.find((ref) => ref.name === "external-checkout")?.current); - assert.isFalse(refreshedRefs.refs.find((ref) => ref.name === initialBranch)?.current); - }), - ).pipe(Effect.provide(layerTest)), -); - -it.effect("backs off and logs failed fetch attempts across linked worktrees", () => - Effect.scoped( - Effect.gen(function* () { - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const fetchAttempts = yield* Ref.make(0); - const failingFetchSpawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command)) { - return yield* Effect.die("expected a standard Git command"); - } - if (command.args.includes("fetch") && command.args.includes("--quiet")) { - yield* Ref.update(fetchAttempts, (count) => count + 1); - return makeNonRepositoryHandle(); - } - return yield* delegate.spawn(command); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner), - ); - const warnings: string[] = []; - const logger = Logger.make(({ message }) => { - warnings.push(String(message)); - }); - const readRemoteStatus = (workingDirectory: string) => - driver - .statusDetailsRemote(workingDirectory) - .pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger]))); - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-vcs-driver-remote-"); - const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); - const pathService = yield* Path.Path; - const worktreePath = pathService.join(worktreesRoot, "linked"); - const runGit = (workingDirectory: string, args: ReadonlyArray) => - driver.execute({ - operation: "GitVcsDriver.test.upstreamRefreshBackoff", - cwd: workingDirectory, - args, - timeoutMs: 10_000, - }); + const refs = yield* driver.listRefs({ cwd, refresh: true }); + const worktreePath = refs.refs.find((ref) => ref.current)?.worktreePath; - yield* driver.initRepo({ cwd }); - yield* runGit(cwd, ["config", "user.email", "test@test.com"]); - yield* runGit(cwd, ["config", "user.name", "Test"]); - yield* writeTextFile(cwd, "README.md", "# test\n"); - yield* runGit(cwd, ["add", "."]); - yield* runGit(cwd, ["commit", "-m", "initial commit"]); - const initialBranch = (yield* runGit(cwd, ["branch", "--show-current"])).stdout.trim(); - yield* runGit(remote, ["init", "--bare"]); - yield* runGit(cwd, ["remote", "add", "origin", remote]); - yield* runGit(cwd, ["push", "-u", "origin", initialBranch]); - yield* runGit(cwd, ["worktree", "add", "-b", "feature/linked", worktreePath]); - yield* runGit(worktreePath, [ - "branch", - "--set-upstream-to", - `origin/${initialBranch}`, - "feature/linked", - ]); - const rootCommonDir = (yield* runGit(cwd, ["rev-parse", "--git-common-dir"])).stdout.trim(); - const linkedCommonDir = (yield* runGit(worktreePath, [ - "rev-parse", - "--git-common-dir", - ])).stdout.trim(); - // Native realpath, since git reports the long form of a directory the - // temp dir may name by its 8.3 short form on Windows. assert.equal( - NodeFS.realpathSync.native(pathService.resolve(cwd, rootCommonDir)), - NodeFS.realpathSync.native(pathService.resolve(worktreePath, linkedCommonDir)), + worktreePath && NodeFS.realpathSync.native(worktreePath), + NodeFS.realpathSync.native(cwd), ); - yield* Ref.set(fetchAttempts, 0); - - yield* readRemoteStatus(cwd); - yield* readRemoteStatus(worktreePath); - assert.equal(yield* Ref.get(fetchAttempts), 1); - assert.lengthOf(warnings, 1); - assert.include(warnings[0], "Background Git fetch failed"); - - yield* TestClock.adjust("29 seconds"); - yield* readRemoteStatus(worktreePath); - assert.equal(yield* Ref.get(fetchAttempts), 1); - assert.lengthOf(warnings, 1); - - yield* TestClock.adjust("1 second"); - yield* readRemoteStatus(cwd); - assert.equal(yield* Ref.get(fetchAttempts), 2); - assert.lengthOf(warnings, 2); - - yield* TestClock.adjust("59 seconds"); - yield* readRemoteStatus(worktreePath); - assert.equal(yield* Ref.get(fetchAttempts), 2); - assert.lengthOf(warnings, 2); - - yield* TestClock.adjust("1 second"); - yield* readRemoteStatus(cwd); - assert.equal(yield* Ref.get(fetchAttempts), 3); - assert.lengthOf(warnings, 3); }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ).pipe(Effect.provide(layerTest)), ); it.effect.each([ @@ -1116,55 +810,90 @@ it.effect("retries a fetch that lost a ref update to another Git process", () => }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), ); -it.layer(layerTest)("GitVcsDriver core integration", (it) => { - describe("process environment", () => { - it.effect("preserves the caller locale for general Git subprocesses", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - - const locale = yield* git( - cwd, - ["-c", 'alias.print-locale=!printf "%s" "$LC_ALL"', "print-locale"], - { LC_ALL: "zh_CN.UTF-8" }, - ); - - assert.equal(locale, "zh_CN.UTF-8"); - }), +describe("rejected git refs", () => { + const remoteUrl = "https://user:token-123@git.example.invalid/org/secret-repo.git"; + const failedPush = `error: failed to push some refs to '${remoteUrl}'`; + const leakedText = [ + "token-123", + "secret-repo", + "git.example.invalid", + "private-branch", + "workflow scope", + ]; + + const failWith = (stderr: string) => { + const spawner = ChildProcessSpawner.make(() => Effect.succeed(makeFailedHandle(stderr))); + const layer = GitVcsDriver.layer.pipe( + Layer.provide(layerServerConfig), + Layer.provideMerge( + Layer.merge( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ), ); - }); + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + return yield* driver + .execute({ + operation: "GitVcsDriver.test.rejectedRef", + cwd: "/repo", + args: ["push", "origin", "HEAD"], + }) + .pipe(Effect.flip); + }).pipe(Effect.provide(layer)); + }; + + it.effect.each([ + { + label: "a ref the remote refused", + stderr: [ + "remote: Unable to determine if workflow can be created; workflow scope may be required.", + `To ${remoteUrl}`, + " ! [remote rejected] private-branch -> private-branch (workflow scope may be required.)", + failedPush, + ], + reason: "push_rejected_by_remote" as const, + detail: "The remote rejected the push.", + }, + { + label: "a non-fast-forward push", + stderr: [ + `To ${remoteUrl}`, + " ! [rejected] private-branch -> private-branch (non-fast-forward)", + failedPush, + ], + reason: "push_rejected_non_fast_forward" as const, + detail: "Pull or rebase", + }, + ])("names $label without quoting stderr", ({ stderr, reason, detail }) => + Effect.gen(function* () { + const error = yield* failWith(stderr.join("\n")); - describe("structured errors", () => { - it.effect("preserves structured spawn context and the platform cause", () => - Effect.gen(function* () { - const parent = yield* makeTmpDir(); - const pathService = yield* Path.Path; - const cwd = pathService.join(parent, "missing"); - const driver = yield* GitVcsDriver.GitVcsDriver; + assert.deepInclude(error, { reason }); + assert.include(error.detail, detail); + for (const text of leakedText) assert.notInclude(error.message, text); + }), + ); - const error = yield* driver - .execute({ - operation: "GitVcsDriver.test.missingCwd", - cwd, - args: ["status", "--short"], - }) - .pipe(Effect.flip); + // A fetch into a diverged local branch prints the same rejected line, but + // never "failed to push some refs". + it.effect("leaves a rejected fetch unclassified", () => + Effect.gen(function* () { + const error = yield* failWith( + [`From ${remoteUrl}`, " ! [rejected] main -> side (non-fast-forward)"].join( + "\n", + ), + ); - assert.deepInclude(error, { - _tag: "GitCommandError", - operation: "GitVcsDriver.test.missingCwd", - command: "git", - argumentCount: 2, - cwd, - detail: "Failed to spawn Git process.", - }); - if (!(error.cause instanceof PlatformError.PlatformError)) { - return assert.fail("expected the original platform error cause"); - } - assert.equal(error.cause.reason._tag, "NotFound"); - assert.notInclude(error.detail, error.cause.message); - }), - ); + assert.equal(error.reason, undefined); + assert.equal(error.detail, "Git command exited with a non-zero status."); + }), + ); +}); +it.layer(layerTest)("GitVcsDriver core integration", (it) => { + describe("structured errors", () => { it.effect("does not retain git arguments or stderr in command failures", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1197,6 +926,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + // regression: #8645 name the cause of a failed git command it.effect("names the branch conflict behind a failed worktree add", () => Effect.gen(function* () { const parent = yield* makeTmpDir(); @@ -1228,6 +958,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + // regression: #8645 name the cause of a failed git command it.effect("names a missing repository behind a failed command", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1251,33 +982,85 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("appends the reason tag to a caller-supplied detail", () => + it.effect.each([ + { action: "worktree add", detail: "git worktree add failed" }, + { action: "switch", detail: "git checkout failed" }, + ])( + "reports a missing ref during $action with stable, redacted diagnostics", + ({ action, detail }) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const path = yield* Path.Path; + const refName = "private/deleted-base"; + const locales: Array = []; + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawner = ChildProcessSpawner.make((command) => { + if ( + ChildProcess.isStandardCommand(command) && + (command.args.includes("worktree") || command.args[0] === "checkout") + ) { + locales.push(command.options.env?.LC_ALL); + } + return delegate.spawn(command); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(layerServerConfig), + ); + const error = + action === "switch" + ? yield* driver.switchRef({ cwd, refName }).pipe(Effect.flip) + : yield* driver + .createWorktree({ + cwd, + refName, + newRefName: "t3/new-thread", + path: path.join(cwd, "worktree"), + }) + .pipe(Effect.flip); + + assert.deepInclude(error, { _tag: "GitCommandError", detail, reason: "ref_not_found" }); + assert.include(error.message, `${detail} (ref_not_found)`); + assert.notInclude(error.message, refName); + assert.notInclude(yield* encodeGitCommandError(error), refName); + assert.deepEqual(locales, ["C"]); + assert.notProperty(error, "stderr"); + assert.notProperty(error, "args"); + }), + ); + + it.effect.each([ + // Git cannot tell a missing revision from a missing file in these. + { label: "an argument that may be a path", args: ["log", "-1", "missing"] }, + { + label: "a pathspec worded like a diagnostic", + args: ["checkout", "HEAD", "--", "fatal: invalid reference: missing"], + }, + // The tag exists; it names a tree, which git reports in the same words. + { + label: "a worktree from a non-commit", + args: ["worktree", "add", "tree-worktree", "tree-tag"], + }, + ])("leaves $label unclassified rather than calling it a missing ref", ({ args }) => Effect.gen(function* () { - const parent = yield* makeTmpDir(); - const pathService = yield* Path.Path; - const cwd = pathService.join(parent, "repo"); - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.makeDirectory(cwd); + const cwd = yield* makeTmpDir(); const driver = yield* GitVcsDriver.GitVcsDriver; yield* initRepoWithCommit(cwd); - yield* git(cwd, [ - "worktree", - "add", - pathService.join(parent, "taken"), - "-b", - "taken-branch", - ]); + if (args.includes("tree-tag")) { + yield* git(cwd, ["tag", "tree-tag", "HEAD^{tree}"]); + } const error = yield* driver - .createWorktree({ + .execute({ + operation: "GitVcsDriver.test.ambiguousMissingName", cwd, - refName: "taken-branch", - path: pathService.join(parent, "second"), + args, + env: { LC_ALL: "C" }, }) .pipe(Effect.flip); - assert.equal(error.detail, "git worktree add failed"); - assert.include(error.message, "git worktree add failed (branch_checked_out_in_worktree)"); + assert.notProperty(error, "reason"); }), ); @@ -1377,7 +1160,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("does not read remote hook output as a git failure reason", () => + it.effect("reads a hook-declined push from git's rejection line, not the hook's output", () => Effect.gen(function* () { const parent = yield* makeTmpDir(); const pathService = yield* Path.Path; @@ -1406,40 +1189,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }) .pipe(Effect.flip); - assert.notProperty(error, "reason"); - assert.notInclude(error.message, "(authentication_failed)"); - }), - ); - - it.effect("does not read hook output as a git failure reason", () => - Effect.gen(function* () { - const parent = yield* makeTmpDir(); - const pathService = yield* Path.Path; - const remote = pathService.join(parent, "origin.git"); - const cwd = pathService.join(parent, "work"); - const fileSystem = yield* FileSystem.FileSystem; - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* fileSystem.makeDirectory(cwd); - yield* initRepoWithCommit(cwd); - yield* git(cwd, ["init", "--bare", remote]); - yield* git(cwd, ["remote", "add", "origin", remote]); - yield* writeTextFile( - cwd, - ".git/hooks/pre-push", - '#!/bin/sh\necho "authentication failed" >&2\nexit 1\n', - ); - yield* fileSystem.chmod(pathService.join(cwd, ".git/hooks/pre-push"), 0o755); - - const error = yield* driver - .execute({ - operation: "GitVcsDriver.test.hookOutput", - cwd, - args: ["push", "origin", "HEAD"], - env: { LC_ALL: "C" }, - }) - .pipe(Effect.flip); - - assert.notProperty(error, "reason"); + assert.deepInclude(error, { reason: "push_rejected_by_remote" }); assert.notInclude(error.message, "(authentication_failed)"); }), ); @@ -1513,31 +1263,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.deepStrictEqual(refs.refs, []); }), ); - - it.effect("does not wrap a remove-worktree command failure in a synthetic error", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const pathService = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const notAWorktree = pathService.join(cwd, "not-a-worktree"); - yield* fileSystem.makeDirectory(notAWorktree); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* driver.initRepo({ cwd }); - - const error = yield* driver.removeWorktree({ cwd, path: notAWorktree }).pipe(Effect.flip); - - assert.deepInclude(error, { - _tag: "GitCommandError", - operation: "GitVcsDriver.removeWorktree", - command: "git", - argumentCount: 6, - cwd, - }); - assert.notProperty(error, "cause"); - assert.notProperty(error, "stderr"); - assert.notInclude(error.detail, "Git command failed in"); - }), - ); }); describe("review diff previews", () => { @@ -1657,63 +1382,9 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const paths = splitNullSeparatedGitStdoutPaths({ stdout: "complete.txt\0partial", stdoutTruncated: true, - }); - - assert.deepStrictEqual(paths, ["complete.txt"]); - }), - ); - - it.effect("keeps the final path when NUL-separated git output is complete", () => - Effect.sync(() => { - const paths = splitNullSeparatedGitStdoutPaths({ - stdout: "complete.txt\0final.txt", - stdoutTruncated: false, - }); - - assert.deepStrictEqual(paths, ["complete.txt", "final.txt"]); - }), - ); - - it.effect("honors whitespace filtering for worktree and branch previews", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* git(cwd, ["checkout", "-b", "feature/whitespace"]); - yield* writeTextFile(cwd, "README.md", "# test\n"); - yield* git(cwd, ["add", "README.md"]); - yield* git(cwd, ["commit", "-m", "change whitespace"]); - yield* writeTextFile(cwd, "README.md", "# test\n"); - - const included = yield* driver.getReviewDiffPreview({ - cwd, - baseRef: initialBranch, - ignoreWhitespace: false, - }); - const ignored = yield* driver.getReviewDiffPreview({ - cwd, - baseRef: initialBranch, - ignoreWhitespace: true, - }); - - assert.isNotEmpty(included.sources.find((source) => source.kind === "working-tree")?.diff); - assert.isNotEmpty(included.sources.find((source) => source.kind === "branch-range")?.diff); - assert.strictEqual( - ignored.sources.find((source) => source.kind === "working-tree")?.diff, - "", - ); - assert.deepStrictEqual( - ignored.sources.find((source) => source.kind === "working-tree")?.files, - [], - ); - assert.deepStrictEqual( - ignored.sources.find((source) => source.kind === "branch-range")?.files, - [], - ); - assert.strictEqual( - ignored.sources.find((source) => source.kind === "branch-range")?.diff, - "", - ); + }); + + assert.deepStrictEqual(paths, ["complete.txt"]); }), ); @@ -1881,21 +1552,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("keeps untracked files visible before the first commit", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* driver.initRepo({ cwd }); - yield* writeTextFile(cwd, "untracked.txt", "visible before HEAD\n"); - - const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); - const source = preview.sources.find((candidate) => candidate.kind === "working-tree"); - - assert.include(source?.diff, "visible before HEAD"); - assert.equal(source?.truncated, false); - }), - ); - it.effect.each([false, true])( "keeps the preceding second cached in review previews (split: %s)", (splitIndex) => @@ -2151,32 +1807,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("attributes working-tree filesystem failures to the failing operation", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - - const error = yield* driver - .getReviewDiffFileContents( - makeReviewDiffFileContentsInput(cwd, { - changeType: "new", - oldPath: "missing.ts", - newPath: "missing.ts", - }), - ) - .pipe(Effect.flip); - - assert.deepInclude(error, { - _tag: "GitCommandError", - operation: "GitVcsDriver.getReviewDiffFileContents.workingTree.fs.realPath", - command: "fs.realPath", - cwd, - detail: "Could not resolve diff file 'missing.ts'.", - }); - }), - ); - it.effect("loads new and deleted files without reading their missing side", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2352,21 +1982,21 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, [ "config", "branch.feature/configured-base.gh-merge-base", - "upstream/main", + "refs/remotes/upstream/main", ]); yield* writeTextFile(cwd, "upstream.txt", "feature edits upstream file\n"); for (const shadowRef of ["refs/heads/upstream/main", "refs/tags/upstream/main"]) { yield* git(cwd, ["update-ref", shadowRef, "HEAD~2"]); const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); assert.deepStrictEqual(status.branchChanges, { - baseRef: "upstream/main", + baseRef: "refs/remotes/upstream/main", insertions: 2, deletions: 1, }); assert.strictEqual(status.aheadCount, 1); const preview = yield* driver.getReviewDiffPreview({ cwd }); const changes = preview.sources.find((source) => source.kind === "branch-range")!; - assert.strictEqual(changes.baseRef, "upstream/main"); + assert.strictEqual(changes.baseRef, "refs/remotes/upstream/main"); assert.deepStrictEqual(changes.files, [ { path: "feature.txt", previousPath: null, additions: 1, deletions: 0 }, { path: "upstream.txt", previousPath: null, additions: 1, deletions: 1 }, @@ -2507,28 +2137,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("skips Changes totals instead of indexing thousands of untracked files", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* Effect.forEach( - Array.from({ length: 5_001 }, (_, index) => `bulk/${index}.txt`), - (file) => writeTextFile(cwd, file, "x\n"), - { concurrency: 32, discard: true }, - ); - yield* writeTextFile(cwd, "README.md", "changed\n"); - - const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); - assert.isTrue(status.hasWorkingTreeChanges); - assert.isUndefined(status.branchChanges); - - const preview = yield* driver.getReviewDiffPreview({ cwd }); - assert.isNotEmpty(preview.sources); - assert.isTrue(preview.sources.every((source) => source.truncated)); - }), - ); - it.effect("Changes does not show newer base commits as deletions", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2581,6 +2189,94 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reads Changes totals without minimizing and keeps review diffs minimal", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["config", "diff.algorithm", "myers"]); + // Repeated lines make the default and minimal algorithms choose different edits. + const lines = Array.from({ length: 2000 }, (_, index) => index); + yield* writeTextFile( + cwd, + "repeated.txt", + lines.map((index) => `${(index ^ (index >> 3)) % 16}\n`).join(""), + ); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "repeated lines"]); + yield* git(cwd, ["checkout", "-b", "feature/totals"]); + yield* writeTextFile( + cwd, + "repeated.txt", + lines.map((index) => `${(index ^ (index >> 5)) % 16}\n`).join(""), + ); + const defaultStat = yield* git(cwd, ["diff", "--numstat", "HEAD", "--"]); + const minimalStat = yield* git(cwd, ["diff", "--minimal", "--numstat", "HEAD", "--"]); + assert.notEqual(defaultStat, minimalStat); + const [insertions, deletions] = defaultStat.split("\t"); + const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); + assert.deepStrictEqual(status.branchChanges, { + baseRef: initialBranch, + insertions: Number(insertions), + deletions: Number(deletions), + }); + + const [additions, removals] = minimalStat.split("\t"); + const minimalPatch = yield* git(cwd, [ + "diff", + "--minimal", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "HEAD", + "--", + ]); + const preview = yield* driver.getReviewDiffPreview({ cwd }); + for (const source of preview.sources) { + assert.deepStrictEqual(source.files, [ + { + path: "repeated.txt", + previousPath: null, + additions: Number(additions), + deletions: Number(removals), + }, + ]); + assert.strictEqual(source.diff.trim(), minimalPatch); + } + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "lists the real repository through a symlink when a decoy repo occupies the lexical common dir", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const sandbox = yield* makeTmpDir("git-symlink-subdir-"); + yield* initRepoWithCommit(sandbox); + yield* git(sandbox, ["branch", "-M", "decoy-only"]); + const repo = pathService.join(sandbox, "repo"); + const link = pathService.join(sandbox, "nest", "link"); + yield* fileSystem.makeDirectory(pathService.join(repo, "sub", "dir"), { + recursive: true, + }); + yield* fileSystem.makeDirectory(pathService.dirname(link), { recursive: true }); + yield* fileSystem.symlink(pathService.join(repo, "sub", "dir"), link); + + yield* initRepoWithCommit(repo); + yield* git(repo, ["checkout", "-b", "real-only"]); + + // The decoy must exist before the first lookup, which caches repository paths. + const refs = yield* (yield* GitVcsDriver.GitVcsDriver).listRefs({ cwd: link }); + assert.isTrue(refs.isRepo); + assert.isFalse(refs.refs.some((ref) => ref.name === "decoy-only")); + assert.isTrue(refs.refs.some((ref) => ref.name === "real-only")); + }), + ); + it.effect("reads Changes totals with untracked files when requested", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2605,33 +2301,76 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("reports non-repository directories without failing", () => + it.effect("Changes compares the default branch with its tracking upstream, not origin", () => Effect.gen(function* () { + // `origin` is a fork whose main is behind; the checkout tracks the + // canonical repository through a second remote (#15314). + const canonical = yield* makeTmpDir(); + yield* initRepoWithCommit(canonical); + yield* git(canonical, ["branch", "-M", "main"]); + const fork = yield* makeTmpDir(); + yield* git(fork, ["clone", "--quiet", canonical, "."]); + yield* writeTextFile(canonical, "canonical.txt", "one\ntwo\nthree\n"); + yield* git(canonical, ["add", "canonical.txt"]); + yield* git(canonical, ["commit", "-m", "canonical moves ahead"]); + const cwd = yield* makeTmpDir(); const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["init", "-b", "main"]); + yield* git(cwd, ["remote", "add", "origin", fork]); + yield* git(cwd, ["remote", "add", "upstream", canonical]); + yield* git(cwd, ["fetch", "--quiet", "--all"]); + yield* git(cwd, ["reset", "--hard", "--quiet", "upstream/main"]); + yield* git(cwd, ["branch", "--set-upstream-to=upstream/main", "main"]); - const refs = yield* driver.listRefs({ cwd }); - assert.equal(refs.isRepo, false); - assert.deepStrictEqual(refs.refs, []); + const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); + assert.deepStrictEqual(status.branchChanges, { + baseRef: "upstream/main", + insertions: 0, + deletions: 0, + }); + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const changes = preview.sources.find((source) => source.kind === "branch-range")!; + assert.strictEqual(changes.baseRef, "upstream/main"); + assert.deepStrictEqual(changes.files, []); + + // Without an upstream the primary remote's copy stays the base. + yield* git(cwd, ["branch", "--unset-upstream", "main"]); + const untracked = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); + assert.deepStrictEqual(untracked.branchChanges, { + baseRef: "origin/main", + insertions: 3, + deletions: 0, + }); }), ); - it.effect("reports refName and dirty state for a repository", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - yield* writeTextFile(cwd, "feature.ts", "export const value = 1;\n"); - - const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + it.effect( + "counts many untracked files exactly while collapsing their directory in status", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(cwd, ["checkout", "-b", "feature/many-untracked"]); + yield* Effect.forEach( + Array.from({ length: 1_001 }, (_, index) => `bulk/${index}.txt`), + (file) => writeTextFile(cwd, file, "one\ntwo\n"), + { concurrency: 32, discard: true }, + ); - assert.equal(status.isRepo, true); - assert.equal(status.branch, initialBranch); - assert.equal(status.hasWorkingTreeChanges, true); - assert.include( - status.workingTree.files.map((file) => file.path), - "feature.ts", - ); - }), + const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); + assert.deepStrictEqual(status.branchChanges, { + baseRef: "main", + insertions: 2_002, + deletions: 0, + }); + assert.deepStrictEqual( + status.workingTree.files.map((file) => file.path), + ["bulk/"], + ); + }), ); it.effect("reports changes to a file named HEAD", () => @@ -2655,91 +2394,98 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("reports default-branch delta separately from upstream delta", () => + it.effect("reports renamed and non-ASCII files by paths that can be committed", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-vcs-driver-remote-"); - const { initialBranch } = yield* initRepoWithCommit(cwd); - yield* git(remote, ["init", "--bare"]); - yield* git(cwd, ["remote", "add", "origin", remote]); - yield* git(cwd, ["push", "-u", "origin", initialBranch]); - yield* git(cwd, ["checkout", "-b", "feature/synced"]); - yield* writeTextFile(cwd, "feature.txt", "feature\n"); - yield* git(cwd, ["add", "feature.txt"]); - yield* git(cwd, ["commit", "-m", "feature commit"]); - yield* git(cwd, ["push", "-u", "origin", "feature/synced"]); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "src/old.ts", "one\ntwo\nthree\n"); + yield* writeTextFile(cwd, "café notes.md", "draft\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add files"]); + yield* git(cwd, ["mv", "src/old.ts", "src/new.ts"]); + yield* writeTextFile(cwd, "src/new.ts", "one\ntwo\nthree\nfour\n"); + yield* writeTextFile(cwd, "café notes.md", "draft\nedit\n"); + const driver = yield* GitVcsDriver.GitVcsDriver; - const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + const status = yield* driver.statusDetails(cwd); - assert.equal(status.hasUpstream, true); - assert.equal(status.aheadCount, 0); - assert.equal(status.behindCount, 0); - assert.equal(status.aheadOfDefaultCount, 1); + assert.deepEqual(status.workingTree.files, [ + { path: "café notes.md", insertions: 1, deletions: 0 }, + { path: "src/new.ts", insertions: 1, deletions: 0 }, + { path: "src/old.ts", insertions: 0, deletions: 0 }, + ]); + yield* driver.commit(cwd, "Commit selection", "", { + stage: { filePaths: status.workingTree.files.map((file) => file.path) }, + }); + assert.equal(yield* git(cwd, ["status", "--porcelain"]), ""); }), ); - it.effect("can read local files without traversing revision history", () => + it.effect("commits selected files from a nested project directory", () => Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-vcs-driver-remote-"); - const { initialBranch } = yield* initRepoWithCommit(cwd); - yield* git(remote, ["init", "--bare"]); - yield* git(cwd, ["remote", "add", "origin", remote]); - yield* git(cwd, ["push", "-u", "origin", initialBranch]); - yield* git(cwd, ["checkout", "-b", "feature/local-only"]); - yield* writeTextFile(cwd, "feature.txt", "feature\n"); - yield* git(cwd, ["add", "feature.txt"]); - yield* git(cwd, ["commit", "-m", "feature commit"]); - yield* writeTextFile(cwd, "feature.txt", "feature\nlocal edit\n"); - yield* writeTextFile(cwd, "untracked.txt", "untracked\n"); + const path = yield* Path.Path; + // A root basename ending in whitespace catches a root lookup that trims Git's output. + const cwd = path.join(yield* makeTmpDir(), "repo "); + yield* (yield* FileSystem.FileSystem).makeDirectory(cwd); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "nested/tracked.txt", "one\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add nested file"]); + yield* writeTextFile(cwd, "nested/tracked.txt", "one\ntwo\n"); + yield* writeTextFile(cwd, "nested/new.txt", "new\n"); + yield* writeTextFile(cwd, "README.md", "# test\nleft out\n"); + const nestedCwd = path.join(cwd, "nested"); + const driver = yield* GitVcsDriver.GitVcsDriver; - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const commands: ReadonlyArray[] = []; - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command)) { - return yield* Effect.die("expected a standard Git command"); - } - commands.push(command.args); - return yield* delegate.spawn(command); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provide(layerServerConfig), + const status = yield* driver.statusDetails(nestedCwd); + + assert.deepEqual(status.workingTree.files, [ + { path: "nested/new.txt", insertions: 0, deletions: 0 }, + { path: "nested/tracked.txt", insertions: 1, deletions: 0 }, + { path: "README.md", insertions: 1, deletions: 0 }, + ]); + // The root is what the paths above are relative to, not the nested cwd, and it keeps + // the trailing space so the resolved editor target is the file that exists. + assert.equal(status.workingTree.rootPath, NodeFS.realpathSync.native(cwd)); + assert.equal( + NodeFS.existsSync(path.join(status.workingTree.rootPath!, "nested", "new.txt")), + true, ); + yield* driver.commit(nestedCwd, "Commit nested selection", "", { + stage: { filePaths: ["nested/new.txt", "nested/tracked.txt"] }, + }); + assert.equal(yield* git(cwd, ["status", "--porcelain"]), "M README.md"); + }), + ); - for (const hasUpstream of [false, true]) { - if (hasUpstream) { - yield* git(cwd, ["push", "-u", "origin", "feature/local-only"]); - } - commands.length = 0; - const local = yield* driver.statusDetailsLocal(cwd, { includeDivergence: false }); - assert.isFalse(commands.some((args) => args.includes("rev-list"))); - assert.isTrue(commands.some((args) => args.includes("--no-ahead-behind"))); - assert.equal(local.hasUpstream, hasUpstream); - assert.isTrue(local.hasWorkingTreeChanges); - assert.deepEqual(local.workingTree.files, [ - { path: "feature.txt", insertions: 1, deletions: 0 }, - { path: "untracked.txt", insertions: 0, deletions: 0 }, - ]); + it.effect("reports the linked worktree as the root of a nested directory in it", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const worktreePath = yield* makeTmpDir("git-vcs-driver-linked-"); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "nested/tracked.txt", "one\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add nested file"]); + yield* git(cwd, ["worktree", "add", "-b", "feature/linked", worktreePath]); + yield* writeTextFile(worktreePath, "nested/tracked.txt", "one\ntwo\n"); + yield* writeTextFile(worktreePath, "nested/new.txt", "new\n"); + const nestedCwd = (yield* Path.Path).join(worktreePath, "nested"); + const driver = yield* GitVcsDriver.GitVcsDriver; - commands.length = 0; - const full = yield* driver.statusDetailsLocal(cwd); - assert.isTrue(commands.some((args) => args.includes("rev-list"))); - assert.equal(full.aheadCount, hasUpstream ? 0 : 1); - assert.equal(full.aheadOfDefaultCount, 1); - assert.deepEqual(local, { - ...full, - aheadCount: 0, - behindCount: 0, - aheadOfDefaultCount: 0, - }); - } + const status = yield* driver.statusDetails(nestedCwd); + + assert.deepEqual(status.workingTree.files, [ + { path: "nested/new.txt", insertions: 0, deletions: 0 }, + { path: "nested/tracked.txt", insertions: 1, deletions: 0 }, + ]); + assert.equal( + NodeFS.realpathSync.native(status.workingTree.rootPath!), + NodeFS.realpathSync.native(worktreePath), + ); }), ); - it.effect("reports remote divergence without reading working-tree details", () => + it.effect("reports default-branch delta separately from upstream delta", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const remote = yield* makeTmpDir("git-vcs-driver-remote-"); @@ -2747,23 +2493,18 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* git(cwd, ["remote", "add", "origin", remote]); yield* git(cwd, ["push", "-u", "origin", initialBranch]); - yield* git(cwd, ["checkout", "-b", "feature/remote-status"]); + yield* git(cwd, ["checkout", "-b", "feature/synced"]); yield* writeTextFile(cwd, "feature.txt", "feature\n"); yield* git(cwd, ["add", "feature.txt"]); yield* git(cwd, ["commit", "-m", "feature commit"]); - yield* git(cwd, ["push", "-u", "origin", "feature/remote-status"]); - yield* writeTextFile(cwd, "untracked.txt", "local-only\n"); + yield* git(cwd, ["push", "-u", "origin", "feature/synced"]); - const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetailsRemote(cwd); + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); - assert.equal(status.isRepo, true); - assert.equal(status.branch, "feature/remote-status"); assert.equal(status.hasUpstream, true); assert.equal(status.aheadCount, 0); assert.equal(status.behindCount, 0); assert.equal(status.aheadOfDefaultCount, 1); - assert.notProperty(status, "workingTree"); - assert.notProperty(status, "hasWorkingTreeChanges"); }), ); @@ -3043,7 +2784,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("optionally includes remote refs that match local branches", () => + it.effect("lists root and upstream metadata and optionally includes matching remote refs", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const remote = yield* makeTmpDir("git-vcs-driver-remote-"); @@ -3051,6 +2792,10 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* git(cwd, ["remote", "add", "origin", remote]); yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["remote", "add", "team/upstream", remote]); + yield* git(cwd, ["push", "team/upstream", "HEAD:refs/heads/feature/a"]); + yield* git(cwd, ["branch", "--track", "feature/a", "team/upstream/feature/a"]); + yield* git(cwd, ["branch", "--no-track", "local-only"]); const driver = yield* GitVcsDriver.GitVcsDriver; const deduplicated = yield* driver.listRefs({ cwd }); @@ -3059,7 +2804,19 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { false, ); - const complete = yield* driver.listRefs({ cwd, includeMatchingRemoteRefs: true }); + const worktreePath = yield* makeTmpDir("git-vcs-driver-worktree-"); + yield* git(cwd, ["worktree", "add", worktreePath, "feature/a"]); + yield* writeTextFile(worktreePath, "packages/app/file.txt", "nested project\n"); + const pathService = yield* Path.Path; + const nestedCwd = pathService.join(worktreePath, "packages", "app"); + const complete = yield* driver.listRefs({ + cwd: nestedCwd, + includeMatchingRemoteRefs: true, + }); + const untracked = complete.refs.find((ref) => ref.name === "local-only"); + assert.ok(untracked); + assert.equal("upstreamRef" in untracked, false); + assert.deepEqual(decodeVcsRef(untracked), untracked); assert.equal( complete.refs.some((ref) => ref.name === initialBranch), true, @@ -3078,6 +2835,25 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(remoteOnly.refs.length, 1); assert.equal(remoteOnly.refs[0]?.name, `origin/${initialBranch}`); assert.equal(remoteOnly.refs[0]?.isRemote, true); + + const tracked = complete.refs.find((ref) => ref.name === "feature/a"); + assert.ok(tracked); + assert.deepEqual(decodeVcsRef(tracked), { + ...tracked, + upstreamRef: "refs/remotes/team/upstream/feature/a", + }); + const aliasCwd = pathService.join(cwd, "worktree-alias"); + NodeFS.symlinkSync(worktreePath, aliasCwd, "junction"); + const aliased = yield* driver.listRefs({ cwd: aliasCwd }); + const expected = [ + { ...complete, isAtRepositoryRoot: false }, + { ...aliased, isAtRepositoryRoot: true }, + ]; + assert.deepEqual([complete, aliased], expected); + assert.deepEqual( + [decodeVcsListRefsResult(complete), decodeVcsListRefsResult(aliased)], + expected, + ); }), ); @@ -3101,32 +2877,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("creates, checks out, renames, and lists refs", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - - yield* driver.createRef({ cwd, refName: "feature/original" }); - const switchRef = yield* driver.switchRef({ cwd, refName: "feature/original" }); - assert.equal(switchRef.refName, "feature/original"); - - const renamed = yield* driver.renameBranch({ - cwd, - oldBranch: "feature/original", - newBranch: "feature/renamed", - }); - assert.equal(renamed.branch, "feature/renamed"); - assert.equal(yield* git(cwd, ["branch", "--show-current"]), "feature/renamed"); - - const refs = yield* driver.listRefs({ cwd }); - assert.equal( - refs.refs.find((refName) => refName.name === "feature/renamed")?.current, - true, - ); - }), - ); - it.effect("preserves exact custom names and refuses collisions without a suffix", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -3153,23 +2903,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("returns the existing refName when rename source and target match", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - - const current = yield* git(cwd, ["branch", "--show-current"]); - const result = yield* driver.renameBranch({ - cwd, - oldBranch: current, - newBranch: current, - }); - - assert.equal(result.branch, current); - }), - ); - it.effect("rejects a missing branch without restoring a matching dirty file", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -3252,83 +2985,13 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal((yield* driver.switchRef({ cwd, refName: "-" })).refName, "keep"); const previous = yield* driver.createWorktree({ cwd, - path: pathService.join(worktrees, "previous"), - refName: "-", - }); - assert.equal( - yield* git(previous.worktree.path, ["branch", "--show-current"]), - initialBranch, - ); - }), - ); - - it.effect("still creates and reuses remote tracking branches and allows detached refs", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-remote-"); - const { initialBranch } = yield* initRepoWithCommit(cwd); - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const commands: Array> = []; - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (ChildProcess.isStandardCommand(command)) commands.push(command.args); - return yield* delegate.spawn(command); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provide(layerServerConfig), + path: pathService.join(worktrees, "previous"), + refName: "-", + }); + assert.equal( + yield* git(previous.worktree.path, ["branch", "--show-current"]), + initialBranch, ); - const assertCheckout = ( - checkoutArgs: ReadonlyArray, - existenceReads: number, - trackingReads: number, - ) => { - assert.deepEqual( - commands.filter((args) => args[0] === "checkout"), - [checkoutArgs], - ); - assert.equal(commands.filter((args) => args[0] === "show-ref").length, existenceReads); - assert.equal(commands.filter((args) => args[0] === "for-each-ref").length, trackingReads); - assert.deepEqual( - commands.filter((args) => args[0] === "branch"), - [["branch", "--show-current"]], - ); - }; - yield* git(remote, ["init", "--bare"]); - yield* git(cwd, ["remote", "add", "origin", remote]); - yield* git(cwd, ["push", "origin", "HEAD:refs/heads/remote-only"]); - - for (let attempt = 0; attempt < 2; attempt += 1) { - commands.length = 0; - const result = yield* driver.switchRef({ cwd, refName: "origin/remote-only" }); - assert.equal(result.refName, attempt === 0 ? "remote-only" : "review/renamed"); - assertCheckout( - attempt === 0 - ? ["checkout", "--track", "origin/remote-only", "--"] - : ["checkout", "review/renamed", "--"], - attempt === 0 ? 3 : 2, - 1, - ); - assert.equal( - yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), - "origin/remote-only", - ); - if (attempt === 0) yield* git(cwd, ["branch", "-m", "review/renamed"]); - yield* driver.switchRef({ cwd, refName: initialBranch }); - } - yield* git(cwd, ["branch", "origin/remote-only"]); - commands.length = 0; - const shadowed = yield* driver.switchRef({ cwd, refName: "origin/remote-only" }); - assert.equal(shadowed.refName, "origin/remote-only"); - assertCheckout(["checkout", "origin/remote-only", "--"], 2, 0); - - const commit = yield* git(cwd, ["rev-parse", "HEAD"]); - commands.length = 0; - const detached = yield* driver.switchRef({ cwd, refName: commit }); - assert.equal(detached.refName, null); - assertCheckout(["checkout", commit, "--"], 2, 0); - assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), commit); }), ); }); @@ -3465,17 +3128,22 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const commit = yield* git(cwd, ["rev-parse", "origin/release/v2"]); const path = yield* Path.Path; const driver = yield* GitVcsDriver.GitVcsDriver; + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "remote"); yield* driver.createWorktree({ cwd, - path: path.join(yield* makeTmpDir("git-worktrees-"), "remote"), + path: worktreePath, refName: commit, newRefName: "feature/remote", baseRefName: baseRef, }); + const preview = yield* driver.getReviewDiffPreview({ cwd: worktreePath }); + const comparisonBase = preview.sources.find( + (source) => source.kind === "branch-range", + )!.baseRef; assert.equal( - yield* driver.readConfigValue(cwd, "branch.feature/remote.gh-merge-base"), - "release/v2", + yield* git(worktreePath, ["rev-parse", `${comparisonBase}^{commit}`]), + commit, ); }), ); @@ -3733,84 +3401,95 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("reports checkout progress during parallel worktree creation", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - for (let index = 0; index < 200; index += 1) { - yield* writeTextFile(cwd, `file-${index}.txt`, `${index}\n`); - } - yield* git(cwd, ["add", "."]); - yield* git(cwd, ["commit", "-m", "add files"]); - const pathService = yield* Path.Path; - const worktreePath = pathService.join( - yield* makeTmpDir("git-worktrees-"), - "progress-worktree", - ); - const driver = yield* GitVcsDriver.GitVcsDriver; - const seen = yield* Ref.make>( - [], - ); - const claimed = yield* Ref.make<{ path: string; existed: boolean } | null>(null); - - yield* driver.createWorktree( - { cwd, path: worktreePath, refName: initialBranch, newRefName: "feature/progress" }, - { - progress: { - onWorktreeClaimed: (path) => - Ref.set(claimed, { path, existed: NodeFS.existsSync(path) }), - onCheckoutProgress: (update) => Ref.update(seen, (all) => [...all, update]), - }, - }, - ); - assert.deepEqual(yield* Ref.get(claimed), { path: worktreePath, existed: true }); - - const updates = yield* Ref.get(seen); - assert.isAbove(updates.length, 1); - assert.equal(updates.at(-1)?.percent, 100); - assert.equal(updates.at(-1)?.total, 201); - const completed = updates.map((update) => update.completed); - assert.deepEqual( - completed, - completed.toSorted((a, b) => a - b), - ); - }), - ); - - it.effect("creates and removes a worktree and its new local branch", () => + it.effect("still creates the worktree when the base-ref config is locked", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); const pathService = yield* Path.Path; - const worktreePath = pathService.join( - yield* makeTmpDir("git-worktrees-"), - "feature-worktree", - ); + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "locked-config"); + yield* writeTextFile(cwd, ".git/config.lock", ""); const driver = yield* GitVcsDriver.GitVcsDriver; const created = yield* driver.createWorktree({ cwd, path: worktreePath, refName: initialBranch, - newRefName: "feature/worktree", + newRefName: "feature/locked-config", + baseRefName: initialBranch, }); - assert.equal(created.worktree.path, worktreePath); - assert.equal(created.worktree.refName, "feature/worktree"); - assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree"); - - yield* driver.removeWorktree({ cwd, path: worktreePath }); - yield* driver.deleteLocalBranch({ - cwd, - refName: "feature/worktree", - force: true, + assert.deepEqual(created.worktree, { + path: worktreePath, + refName: "feature/locked-config", }); - const fileSystem = yield* FileSystem.FileSystem; - assert.equal(yield* fileSystem.exists(worktreePath), false); - assert.notInclude(yield* driver.listLocalBranchNames(cwd), "feature/worktree"); + assert.equal( + yield* git(worktreePath, ["branch", "--show-current"]), + "feature/locked-config", + ); + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, "README.md")), + "# test\n", + ); + assert.equal( + yield* driver.readConfigValue(worktreePath, "branch.feature/locked-config.gh-merge-base"), + null, + ); + assert.equal(yield* fileSystem.exists(pathService.join(cwd, ".git/config.lock")), true); }), ); + it.effect.each([false, true])( + "cleans up links left by successful worktree removal, preserving ordinary files: %s", + (keepFile) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const outside = yield* makeTmpDir("git-worktree-link-target-"); + yield* writeTextFile(outside, "keep.txt", "external target\n"); + const worktreePath = pathService.join(cwd, "leftover"); + const nested = pathService.join(worktreePath, "node_modules"); + yield* fileSystem.makeDirectory(nested, { recursive: true }); + yield* fileSystem.makeDirectory(pathService.join(worktreePath, "empty")); + NodeFS.symlinkSync(outside, pathService.join(worktreePath, "linked"), "junction"); + NodeFS.symlinkSync(outside, pathService.join(nested, "pkg"), "junction"); + if (keepFile) { + yield* writeTextFile(worktreePath, "notes/draft.txt", "new work\n"); + } + + // Git removes symlinks on Unix; simulate its successful Windows removal + // leaving junctions behind so the cleanup is exercised on every platform. + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && + command.args.join(" ").includes("worktree remove") + ? Effect.succeed(makeSuccessfulHandle("")) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(layerServerConfig), + ); + + yield* driver.removeWorktree({ cwd, path: worktreePath }); + + assert.equal( + yield* fileSystem.readFileString(pathService.join(outside, "keep.txt")), + "external target\n", + ); + if (keepFile) { + assert.deepEqual(yield* fileSystem.readDirectory(worktreePath), ["notes"]); + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, "notes/draft.txt")), + "new work\n", + ); + } else { + assert.equal(yield* fileSystem.exists(worktreePath), false); + } + }), + ); + it.effect("keeps a worktree whose untracked files status is configured to hide", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -3839,67 +3518,97 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("allows worktree removal to run longer than the default command timeout", () => + it.effect("leaves a folder alone that only shares the worktree's short name", () => Effect.gen(function* () { - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const removalStarted = yield* Deferred.make(); - const delayedRemovalSpawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if ( - ChildProcess.isStandardCommand(command) && - command.args.join(" ").includes("worktree remove") - ) { - yield* Deferred.succeed(removalStarted, undefined); - yield* Effect.sleep("31 seconds"); - } - return yield* delegate.spawn(command); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, delayedRemovalSpawner), - Effect.provide(layerServerConfig), - ); const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); - const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; - const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "slow-removal"); - + const fileSystem = yield* FileSystem.FileSystem; + const driver = yield* GitVcsDriver.GitVcsDriver; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "short"); yield* driver.createWorktree({ cwd, path: worktreePath, refName: initialBranch, - newRefName: "feature/slow-removal", + newRefName: "feature/short", }); + // Unrelated to the worktree, but resolving "short" against cwd lands here. + const outside = yield* makeTmpDir("git-worktree-link-target-"); + const unrelated = pathService.join(cwd, "short"); + NodeFS.mkdirSync(unrelated); + NodeFS.symlinkSync(outside, pathService.join(unrelated, "linked"), "junction"); - const removal = yield* driver - .removeWorktree({ cwd, path: worktreePath, force: true }) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(removalStarted); - yield* TestClock.adjust("31 seconds"); - yield* Fiber.join(removal); + yield* Effect.result(driver.removeWorktree({ cwd, path: "short" })); - assert.equal(yield* fileSystem.exists(worktreePath), false); + assert.isTrue(NodeFS.lstatSync(pathService.join(unrelated, "linked")).isSymbolicLink()); + assert.equal(yield* fileSystem.exists(worktreePath), true); }), ); - it.effect("removes the same worktree path twice without failing", () => + it.effect("force-removes a worktree without deleting what its junctions point to", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, ".gitignore", "node_modules\n"); + yield* git(cwd, ["add", ".gitignore"]); + yield* git(cwd, ["commit", "-m", "ignore node_modules"]); + const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; - const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "shared"); + const outside = yield* makeTmpDir("git-worktree-link-target-"); + yield* writeTextFile(outside, "keep.txt", "keep\n"); + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "linked"); const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/linked", + }); + // Junctions on Windows, the way pnpm links packages; symlinks elsewhere. + NodeFS.mkdirSync(pathService.join(worktreePath, "node_modules")); + NodeFS.symlinkSync( + outside, + pathService.join(worktreePath, "node_modules", "dep"), + "junction", + ); + NodeFS.symlinkSync(outside, pathService.join(worktreePath, "shared"), "junction"); + yield* driver.removeWorktree({ cwd, path: worktreePath, force: true }); + + assert.deepEqual(NodeFS.readdirSync(pathService.dirname(worktreePath)), []); + assert.equal( + yield* fileSystem.readFileString(pathService.join(outside, "keep.txt")), + "keep\n", + ); + }), + ); + + it.effect("keeps the junctions of a worktree that git refuses to remove", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, ".gitignore", "node_modules\n"); + yield* git(cwd, ["add", ".gitignore"]); + yield* git(cwd, ["commit", "-m", "ignore node_modules"]); + const pathService = yield* Path.Path; + const outside = yield* makeTmpDir("git-worktree-link-target-"); + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "linked"); + const driver = yield* GitVcsDriver.GitVcsDriver; yield* driver.createWorktree({ cwd, path: worktreePath, refName: initialBranch, - newRefName: "feature/shared", + newRefName: "feature/linked", }); + const link = pathService.join(worktreePath, "node_modules"); + NodeFS.symlinkSync(outside, link, "junction"); + yield* writeTextFile(worktreePath, "notes.txt", "draft\n"); - yield* driver.removeWorktree({ cwd, path: worktreePath }); - yield* driver.removeWorktree({ cwd, path: worktreePath }); + const result = yield* Effect.result(driver.removeWorktree({ cwd, path: worktreePath })); + + assert.isTrue(Result.isFailure(result)); + assert.isTrue(NodeFS.lstatSync(link).isSymbolicLink()); + assert.deepEqual(NodeFS.readdirSync(pathService.dirname(worktreePath)), ["linked"]); }), ); @@ -3933,37 +3642,31 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("remote operations", () => { - it.effect( - "fails a required branch fetch instead of using a deleted upstream tracking ref", - () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-vcs-driver-upstream-"); - const { initialBranch } = yield* initRepoWithCommit(cwd); - yield* git(remote, ["init", "--bare"]); - yield* git(cwd, ["remote", "add", "upstream", remote]); - yield* git(cwd, ["push", "upstream", `${initialBranch}:feature/deleted`]); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* driver.fetchRemote({ cwd, remoteName: "upstream", refName: "feature/deleted" }); - yield* git(cwd, ["--git-dir", remote, "update-ref", "-d", "refs/heads/feature/deleted"]); - - const error = yield* driver - .fetchRemote({ - cwd, - remoteName: "upstream", - refName: "upstream/feature/deleted", - requireBranch: true, - }) - .pipe(Effect.flip); + it.effect("ensureRemote reuses a partial clone remote and avoids its name", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "https://github.com/pingdotgg/t3code.git"]); + yield* git(cwd, ["config", "remote.origin.promisor", "true"]); + yield* git(cwd, ["config", "remote.origin.partialclonefilter", "blob:none"]); + const driver = yield* GitVcsDriver.GitVcsDriver; - assert.equal(error.detail, "upstream/feature/deleted was not found."); - assert.equal( - yield* git(cwd, ["rev-parse", "refs/remotes/upstream/feature/deleted"]), - yield* git(cwd, ["rev-parse", "HEAD"]), - ); - }), + const reused = yield* driver.ensureRemote({ + cwd, + preferredName: "origin", + url: "git@github.com:pingdotgg/t3code.git", + }); + assert.equal(reused, "origin"); + const added = yield* driver.ensureRemote({ + cwd, + preferredName: "origin", + url: "https://github.com/another-owner/t3code.git", + }); + assert.equal(added, "origin-1"); + }), ); + // regression: #12485 explain fetch failures without exposing remote output it.effect("explains a real fetch failure for a missing local remote", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -4216,65 +3919,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("remote operations", () => { - it.effect.each(["offline", "auth", "timeout"] as const)( - "does not retry a scoped fetch after %s", - (failure) => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const started = yield* Deferred.make(); - const attempts: Array> = []; - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command)) - return yield* Effect.die("unexpected command"); - if (command.args[0] !== "fetch") return yield* delegate.spawn(command); - attempts.push(command.args); - yield* Deferred.succeed(started, undefined); - return ChildProcessSpawner.makeHandle({ - ...makeNonRepositoryHandle(), - exitCode: - failure === "timeout" - ? Effect.never - : Effect.succeed(ChildProcessSpawner.ExitCode(128)), - stderr: Stream.encodeText( - Stream.make( - failure === "auth" - ? "fatal: Authentication failed" - : "fatal: Could not resolve host", - ), - ), - }); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provide(layerServerConfig), - ); - const fetching = yield* driver - .fetchRemote({ cwd, remoteName: "origin", refName: "main" }) - .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(started); - if (failure === "timeout") { - yield* TestClock.adjust("31 seconds"); - yield* TestClock.adjust("31 seconds"); - } - const result = yield* Fiber.join(fetching); - assert.isTrue(Result.isFailure(result)); - assert.equal(attempts.length, 1); - if (Result.isFailure(result)) { - assert.equal( - result.failure.detail, - failure === "timeout" - ? "Git command timed out." - : failure === "offline" - ? "Git could not reach the remote. Check the server's network connection and remote host, then retry." - : "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry.", - ); - } - }), - ); - it.effect("creates a worktree from the latest fetched remote commit", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -4357,9 +4001,13 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), remoteHead); + const preview = yield* driver.getReviewDiffPreview({ cwd: worktreePath }); + const comparisonBase = preview.sources.find( + (source) => source.kind === "branch-range", + )!.baseRef; assert.equal( - yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.gh-merge-base"), - initialBranch, + yield* git(worktreePath, ["rev-parse", `${comparisonBase}^{commit}`]), + remoteHead, ); assert.equal( yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.remote"), @@ -4376,6 +4024,123 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("fetches the selected worktree base for Changes", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const fork = yield* makeTmpDir("git-fork-"); + const source = yield* makeTmpDir("git-source-"); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "-M", "stable"]); + yield* git(fork, ["init", "--bare"]); + yield* git(source, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", fork]); + yield* git(cwd, ["remote", "add", "upstream", source]); + yield* git(cwd, ["push", "origin", "stable"]); + yield* git(cwd, ["push", "-u", "upstream", "stable:release"]); + const localHead = yield* git(cwd, ["rev-parse", "HEAD"]); + + yield* writeTextFile(cwd, "fork.txt", "fork-only work\n"); + yield* git(cwd, ["add", "fork.txt"]); + yield* git(cwd, ["commit", "-m", "fork work"]); + yield* git(cwd, ["push", "origin", "HEAD:refs/heads/release"]); + yield* git(cwd, ["reset", "--hard", localHead]); + + // Advance the source without updating this checkout's tracking refs. + yield* writeTextFile(cwd, "upstream.txt", "upstream-only work\n"); + yield* git(cwd, ["add", "upstream.txt"]); + yield* git(cwd, ["commit", "-m", "source advance"]); + const remoteHead = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(source, ["--git-dir=.", "fetch", cwd, "stable:refs/heads/release"]); + yield* git(cwd, ["reset", "--hard", localHead]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const fetched = yield* driver.fetchWorktreeBaseCommit({ cwd, refName: "stable" }); + assert.deepEqual(fetched, { + commitSha: remoteHead, + remoteRefName: "upstream/release", + }); + yield* git(cwd, ["update-ref", "-d", "refs/remotes/upstream/release"]); + assert.deepEqual( + yield* driver.fetchWorktreeBaseCommit({ cwd, refName: "upstream/release" }), + { commitSha: remoteHead, remoteRefName: "upstream/release" }, + ); + yield* git(cwd, ["branch", "--unset-upstream", "stable"]); + assert.deepEqual(yield* driver.fetchWorktreeBaseCommit({ cwd, refName: "stable" }), { + commitSha: localHead, + remoteRefName: "origin/stable", + }); + assert.equal(yield* git(cwd, ["rev-parse", "stable"]), localHead); + + const path = yield* Path.Path; + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "feature"); + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: fetched!.commitSha, + newRefName: "feature/from-upstream", + baseRefName: `refs/remotes/${fetched!.remoteRefName}`, + }); + assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), remoteHead); + yield* writeTextFile(worktreePath, "feature.txt", "feature work\n"); + yield* git(worktreePath, ["add", "feature.txt"]); + yield* git(worktreePath, ["commit", "-m", "feature work"]); + + const preview = yield* driver.getReviewDiffPreview({ cwd: worktreePath }); + const changes = preview.sources.find((source) => source.kind === "branch-range")!; + assert.deepStrictEqual(changes.files, [ + { path: "feature.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.include(changes.diff, "+feature work"); + assert.notInclude(changes.diff, "upstream-only work"); + const status = yield* driver.statusDetailsLocal(worktreePath, { + includeBranchChanges: true, + }); + assert.strictEqual(status.aheadCount, 1); + assert.strictEqual(status.branchChanges?.insertions, 1); + assert.strictEqual(status.branchChanges?.deletions, 0); + // gh pr create consumes this setting verbatim as the hosted target branch. + assert.equal( + yield* driver.readConfigValue(worktreePath, "branch.feature/from-upstream.gh-merge-base"), + "release", + ); + assert.equal(changes.baseRef, "upstream/release"); + }), + ); + + it.effect("does not use a deleted remote worktree base's stale tracking commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", `${initialBranch}:release`]); + yield* git(remote, ["--git-dir=.", "update-ref", "-d", "refs/heads/release"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + assert.equal( + yield* git(cwd, ["rev-parse", "refs/remotes/origin/release"]), + yield* git(cwd, ["rev-parse", "HEAD"]), + ); + assert.isNull(yield* driver.fetchWorktreeBaseCommit({ cwd, refName: initialBranch })); + yield* git(cwd, ["remote", "remove", "origin"]); + assert.isNull(yield* driver.fetchWorktreeBaseCommit({ cwd, refName: initialBranch })); + }), + ); + + it.effect("fails instead of falling back when the worktree base remote is unreachable", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["remote", "add", "origin", `${cwd}/missing.git`]); + const driver = yield* GitVcsDriver.GitVcsDriver; + const error = yield* driver + .fetchWorktreeBaseCommit({ cwd, refName: initialBranch }) + .pipe(Effect.flip); + assert.equal(error._tag, "GitCommandError"); + }), + ); + it.effect("pushes with upstream setup and skips when already up to date", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -4481,43 +4246,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("allows pushes to run longer than the default command timeout", () => - Effect.gen(function* () { - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const pushStarted = yield* Deferred.make(); - const delayedPushSpawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (ChildProcess.isStandardCommand(command) && command.args[0] === "push") { - yield* Deferred.succeed(pushStarted, undefined); - yield* Effect.sleep("31 seconds"); - } - return yield* delegate.spawn(command); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, delayedPushSpawner), - Effect.provide(layerServerConfig), - ); - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-remote-"); - yield* initRepoWithCommit(cwd); - yield* git(remote, ["init", "--bare"]); - yield* git(cwd, ["remote", "add", "origin", remote]); - - const pushing = yield* driver - .pushCurrentBranch(cwd, null) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(pushStarted); - yield* TestClock.adjust("31 seconds"); - const pushed = yield* Fiber.join(pushing); - - assert.deepInclude(pushed, { - status: "pushed", - setUpstream: true, - }); - }), - ); - it.effect( "pushes upstream branches to the remote branch name, not the upstream shorthand", () => @@ -4561,6 +4289,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { Effect.gen(function* () { const cwd = yield* makeTmpDir(); const remote = yield* makeTmpDir("git-remote-"); + const upstream = yield* makeTmpDir("git-upstream-"); yield* initRepoWithCommit(cwd); const driver = yield* GitVcsDriver.GitVcsDriver; yield* git(cwd, ["branch", "-M", "main"]); @@ -4570,7 +4299,14 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["checkout", "-b", "dev"]); yield* git(cwd, ["push", "-u", "origin", "dev"]); const devSha = yield* git(cwd, ["rev-parse", "HEAD"]); - yield* git(cwd, ["checkout", "-b", "feature/x", "origin/dev"]); + yield* git(upstream, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "upstream", upstream]); + yield* writeTextFile(cwd, "upstream.txt", "upstream work\n"); + yield* git(cwd, ["add", "-A"]); + yield* driver.commit(cwd, "Add upstream update", ""); + yield* git(cwd, ["push", "upstream", "dev"]); + const upstreamDevSha = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["checkout", "-b", "feature/x", "upstream/dev"]); yield* writeTextFile(cwd, "feature.txt", "feature\n"); yield* git(cwd, ["add", "-A"]); yield* driver.commit(cwd, "Add feature", ""); @@ -4588,11 +4324,19 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { "Add feature", ); assert.equal(yield* git(remote, ["--git-dir=.", "rev-parse", "dev"]), devSha); + assert.equal(yield* git(upstream, ["--git-dir=.", "rev-parse", "dev"]), upstreamDevSha); assert.equal( yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), "origin/feature/x", ); assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.gh-merge-base"), "dev"); + + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const changes = preview.sources.find((source) => source.kind === "branch-range")!; + assert.deepStrictEqual(changes.files, [ + { path: "feature.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.equal(changes.baseRef, "upstream/dev"); }), ); @@ -4738,21 +4482,6 @@ describe("Windows long path configuration", () => { }), ); - it.effect("leaves Git config untouched on other platforms", () => - Effect.gen(function* () { - assert.equal(yield* readGitConfig("linux", "core.longpaths"), "false"); - assert.equal(yield* readGitConfig("linux", "user.name"), "inherited-name"); - }), - ); - - it("extends an inherited count spelled in a different case", () => { - assert.deepStrictEqual(windowsLongPathConfigEnv("win32", { git_config_count: "2" }), { - git_config_count: "3", - GIT_CONFIG_KEY_2: "core.longpaths", - GIT_CONFIG_VALUE_2: "true", - }); - }); - it("extends the per-command count when inherited and per-command casings differ", () => { assert.deepStrictEqual( windowsLongPathConfigEnv("win32", { GIT_CONFIG_COUNT: "0", git_config_count: "1" }), @@ -4799,6 +4528,7 @@ describe("Windows long path configuration", () => { } }); + // regression: #14917 Windows worktrees with long paths failed or stranded it("leaves a count Git would reject for Git to report", () => { for (const count of ["nope", "2x", "-1", "1.5", " ", "1 "]) { assert.deepStrictEqual( diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a74f72042e80..32b5934102fb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1,4 +1,6 @@ import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -6,6 +8,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -37,7 +40,9 @@ import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; +import { removeEmptyDirectory } from "./removeEmptyDirectory.ts"; import { explicitBareRepositoryEnv, gitConfigEnv } from "./VcsProcess.ts"; +import { removeLinkedWorktree } from "./worktreeRemoval.ts"; import { resolveWorktreesDirectory } from "../worktreesDirectory.ts"; import { parseRemoteNames, @@ -73,11 +78,11 @@ const REVIEW_DIFF_ARGS = [ "--no-color", "--no-ext-diff", "--no-textconv", - "--minimal", ...PATCH_RENDER_PREFIX_ARGS, ]; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); +const STATUS_UPSTREAM_FETCH_TIMEOUT = Duration.minutes(5); const STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN = Duration.seconds(30); const STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN = Duration.minutes(15); @@ -91,7 +96,7 @@ const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2); const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5); const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30); const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5); -const STATUS_REMOTE_NAMES_CACHE_TTL = Duration.minutes(5); +const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5); const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ GCM_INTERACTIVE: "never", GIT_ASKPASS: "", @@ -156,6 +161,7 @@ class GitRefsRefreshCacheKey extends Data.Class<{ interface GitRepositoryPaths { readonly gitCommonDir: string; readonly worktreeRoot: string | null; + readonly isAtRepositoryRoot: boolean | undefined; readonly currentBranch: string | null; } @@ -168,6 +174,7 @@ interface GitRefsSnapshot { interface ExecuteGitOptions { stdin?: string | undefined; timeoutMs?: number | null | undefined; + forceKillAfterMs?: number | undefined; allowNonZeroExit?: boolean | undefined; fallbackErrorDetail?: string | undefined; env?: NodeJS.ProcessEnv | undefined; @@ -185,30 +192,6 @@ function parseBranchAb(value: string): { ahead: number; behind: number } { }; } -function parseNumstatEntries( - stdout: string, -): Array<{ path: string; insertions: number; deletions: number }> { - const entries: Array<{ path: string; insertions: number; deletions: number }> = []; - for (const line of stdout.split(/\r?\n/g)) { - if (line.trim().length === 0) continue; - const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); - const rawPath = - pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); - if (rawPath.length === 0) continue; - const added = Number.parseInt(addedRaw ?? "0", 10); - const deleted = Number.parseInt(deletedRaw ?? "0", 10); - const renameArrowIndex = rawPath.indexOf(" => "); - const normalizedPath = - renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; - entries.push({ - path: normalizedPath.length > 0 ? normalizedPath : rawPath, - insertions: Number.isFinite(added) ? added : 0, - deletions: Number.isFinite(deleted) ? deleted : 0, - }); - } - return entries; -} - function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] { const fields = stdout.split("\0"); const files: ReviewDiffFileStat[] = []; @@ -228,25 +211,20 @@ function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] { return files; } -function parsePorcelainPath(line: string): string | null { - if (line.startsWith("? ") || line.startsWith("! ")) { - const simple = line.slice(2).trim(); - return simple.length > 0 ? simple : null; - } - - if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { - return null; - } - - const tabIndex = line.indexOf("\t"); - if (tabIndex >= 0) { - const fromTab = line.slice(tabIndex + 1); - const [filePath] = fromTab.split("\t"); - return filePath?.trim().length ? filePath.trim() : null; - } +// Space-separated fields precede the path in each porcelain v2 record type. The path itself +// may contain spaces, so it is everything after them. +const PORCELAIN_V2_FIELDS_BEFORE_PATH: Record = { + "1": 8, + "2": 9, + u: 10, + "?": 1, + "!": 1, +}; - const parts = line.trim().split(/\s+/g); - const filePath = parts.at(-1) ?? ""; +function parsePorcelainPath(record: string): string | null { + const fieldsBeforePath = PORCELAIN_V2_FIELDS_BEFORE_PATH[record[0] ?? ""]; + if (fieldsBeforePath === undefined) return null; + const filePath = record.split(" ").slice(fieldsBeforePath).join(" "); return filePath.length > 0 ? filePath : null; } @@ -375,7 +353,8 @@ function parseRemoteFetchUrls(stdout: string): Map { for (const line of stdout.split("\n")) { const trimmed = line.trim(); if (trimmed.length === 0) continue; - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + // Partial clones append the promisor filter, e.g. `(fetch) [blob:none]`. + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(trimmed); if (!match) continue; const [, remoteName = "", remoteUrl = "", direction = ""] = match; if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { @@ -483,8 +462,42 @@ const GIT_FAILURE_REASON_PATTERNS: ReadonlyArray already exists` also // covers tag and ref collisions, which are not path collisions. [/fatal: '[^']+' already exists|destination path .+ already exists/i, "path_already_exists"], + // Ambiguous revision/path and pathspec failures can name files, so omit them. + // Anchor to a diagnostic line to avoid matching quoted paths in other errors. + [ + /^fatal: (?:invalid reference: |not a valid object name|needed a single revision|bad revision |'.+' is not a commit and a branch '.+' cannot be created from it)/im, + "ref_not_found", + ], + // `[remote rejected]` is only printed for a push. The remote's explanation + // follows in parentheses; it is server text, so it is never quoted. + [/^[ \t]*![ \t]+\[remote rejected\]/im, "push_rejected_by_remote"], + // A fetch into a diverged local branch prints the same `[rejected]` line, but + // only a push goes on to report "failed to push some refs". + [ + /^[ \t]*![ \t]+\[rejected\][^\n]*\((?:non-fast-forward|fetch first)\)[\s\S]*^error: failed to push some refs/im, + "push_rejected_non_fast_forward", + ], ]; +// Reasons the user can act on get a sentence in place of the generic detail. +const GIT_FAILURE_REASON_DETAILS: Partial> = { + push_rejected_by_remote: + "The remote rejected the push. Push this branch from a terminal to see the server's message.", + push_rejected_non_fast_forward: + "The remote has commits this branch doesn't have. Pull or rebase, then push again.", +}; + +function gitFailureDetail(reason: GitCommandFailureReason | null): string { + return ( + (reason === null ? undefined : GIT_FAILURE_REASON_DETAILS[reason]) ?? + "Git command exited with a non-zero status." + ); +} + +// Existing non-commit objects share the missing-ref fatal diagnostic; Git +// distinguishes them with a separate error line. +const GIT_NOT_A_COMMIT_PATTERN = /^error: .+(?:, not a commit|expected commit type)/im; + // Hooks write to the same stream git does, and nothing distinguishes their text // from git's: a pre-push hook echoing "authentication failed" would otherwise // be read as a credential failure. `remote:` is deliberately excluded — git @@ -511,7 +524,9 @@ function classifyGitFailure(stderr: string): GitCommandFailureReason | null { if (diagnostics.length === 0) return null; if (isNonRepositoryGitStderr(diagnostics)) return "not_a_repository"; for (const [pattern, reason] of GIT_FAILURE_REASON_PATTERNS) { - if (pattern.test(diagnostics)) return reason; + if (!pattern.test(diagnostics)) continue; + if (reason === "ref_not_found" && GIT_NOT_A_COMMIT_PATTERN.test(diagnostics)) continue; + return reason; } return null; } @@ -626,14 +641,16 @@ const nowUnixNano = DateTime.now.pipe( Effect.map((now) => BigInt(DateTime.toEpochMillis(now)) * 1_000_000n), ); -const addCurrentSpanEvent = (name: string, attributes: Record) => - Effect.gen(function* () { - const span = yield* Effect.currentSpan; - const timestamp = yield* nowUnixNano; - yield* Effect.sync(() => { - span.event(name, timestamp, compactTraceAttributes(attributes)); - }); - }).pipe( +const addCurrentSpanEvent = + /* This helper annotates the existing operation span. */ + Effect.fnUntraced( + function* (name: string, attributes: Record) { + const span = yield* Effect.currentSpan; + const timestamp = yield* nowUnixNano; + yield* Effect.sync(() => { + span.event(name, timestamp, compactTraceAttributes(attributes)); + }); + }, Effect.catchTags({ NoSuchElementError: () => Effect.void, }), @@ -940,6 +957,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const { worktreesDir } = yield* ServerConfig.ServerConfig; const crypto = yield* Crypto.Crypto; const hostPlatform = yield* HostProcess.Platform; + const serviceScope = yield* Effect.scope; + const statusFetches = new Map>(); + const statusFetchProcesses = yield* Semaphore.make(4); const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( function* (input) { @@ -983,6 +1003,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ChildProcess.make(executable, commandInput.args, { cwd: commandInput.cwd, env: spawnEnv, + forceKillAfter: input.forceKillAfterMs, }), ) .pipe( @@ -1046,7 +1067,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* new GitCommandError({ ...gitCommandContext(commandInput), ...(reason === null ? {} : { reason }), - detail: "Git command exited with a non-zero status.", + detail: gitFailureDetail(reason), exitCode, stdoutLength: stdout.text.length, stderrLength: stderr.text.length, @@ -1120,6 +1141,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...(options.env !== undefined ? { env: options.env } : {}), allowNonZeroExit: true, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options.forceKillAfterMs !== undefined + ? { forceKillAfterMs: options.forceKillAfterMs } + : {}), ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), ...(options.appendTruncationMarker !== undefined ? { appendTruncationMarker: options.appendTruncationMarker } @@ -1135,7 +1159,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* new GitCommandError({ ...gitCommandContext({ operation, cwd, args }), ...(reason === null ? {} : { reason }), - detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.", + detail: options.fallbackErrorDetail ?? gitFailureDetail(reason), ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), stdoutLength: result.stdout.length, stderrLength: result.stderr.length, @@ -1246,7 +1270,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); }); - const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) { + const readCurrentUpstreamRef = Effect.fn("readCurrentUpstreamRef")(function* (cwd: string) { const upstreamRef = yield* runGitStdout( "GitVcsDriver.resolveCurrentUpstream", cwd, @@ -1257,6 +1281,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { return null; } + return upstreamRef; + }); + + const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* ( + cwd: string, + knownUpstreamRef?: string, + ) { + const upstreamRef = knownUpstreamRef ?? (yield* readCurrentUpstreamRef(cwd)); + if (upstreamRef === null) { + return null; + } const remoteNames = yield* runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( Effect.map(parseRemoteNames), @@ -1268,40 +1303,76 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); - const fetchRemoteForStatus = ( + const fetchRemoteForStatus = Effect.fn("GitVcsDriver.fetchRemoteForStatus.wait")(function* ( gitCommonDir: string, remoteName: string, - ): Effect.Effect => { + ) { const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; // `--no-auto-gc` (a synonym of `--no-auto-maintenance` that older Git also knows) keeps // this poll from starting `git gc --auto`. When that gc fails, for example on a repository // with missing objects, Git retries it on every fetch and leaves a full-size `tmp_pack_*` // behind each time, so a background poll could fill the disk. - return executeGit( - "GitVcsDriver.fetchRemoteForStatus", - fetchCwd, - [ - "--git-dir", - gitCommonDir, - "fetch", - "--quiet", - "--no-tags", - "--no-auto-gc", - "--", - remoteName, - ], - { - env: STATUS_UPSTREAM_REFRESH_ENV, - fallbackErrorDetail: "Background Git fetch exited with a non-zero status.", - timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), - }, - ).pipe(Effect.asVoid); - }; + const args = [ + "--git-dir", + gitCommonDir, + "fetch", + "--quiet", + "--no-tags", + "--no-auto-gc", + "--", + remoteName, + ]; + const key = `${gitCommonDir}\0${remoteName}`; + const failure = statusRemoteRefreshFailures.get(key); + if (failure && (yield* Clock.currentTimeMillis) < failure.retryAt) return; + const fiber = yield* Effect.uninterruptible( + Effect.gen(function* () { + const pending = statusFetches.get(key); + if (pending) return pending; + // Frequent poll deadlines and disconnected clients must not interrupt a + // healthy writer. A separate safety limit prevents a hung transport from + // occupying its worker slot indefinitely. + const started = yield* executeGit("GitVcsDriver.fetchRemoteForStatus", fetchCwd, args, { + env: STATUS_UPSTREAM_REFRESH_ENV, + fallbackErrorDetail: "Background Git fetch exited with a non-zero status.", + timeoutMs: Duration.toMillis(STATUS_UPSTREAM_FETCH_TIMEOUT), + forceKillAfterMs: 1_000, + }).pipe( + Effect.asVoid, + Effect.tap(() => + Effect.sync(() => { + statusRemoteRefreshFailures.delete(key); + }), + ), + Effect.tapError(() => recordStatusRemoteRefreshFailure(key)), + Effect.tapCause((cause) => Effect.logWarning("Background Git fetch failed", cause)), + statusFetchProcesses.withPermits(1), + Effect.onExit(() => + Effect.sync(() => { + statusFetches.delete(key); + }), + ), + Effect.forkIn(serviceScope, { startImmediately: false, uninterruptible: false }), + ); + statusFetches.set(key, started); + return started; + }), + ); + const result = yield* Fiber.join(fiber).pipe( + Effect.timeoutOption(STATUS_UPSTREAM_REFRESH_TIMEOUT), + ); + if (Option.isNone(result)) { + yield* Effect.logDebug( + "Background Git fetch is still running; using the last fetched references.", + ); + } + }); const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( cwd: string, ) { + // Git reports relative paths from the physical cwd, which can differ from a symlink. const commonDirResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.resolveRepositoryPaths.commonDir", cwd, @@ -1330,9 +1401,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const commonDirOutput = commonDirResult.stdout.trim(); + const physicalCwd = yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd)); const resolvedGitCommonDir = path.isAbsolute(commonDirOutput) ? path.normalize(commonDirOutput) - : path.resolve(cwd, commonDirOutput); + : path.resolve(physicalCwd, commonDirOutput); const gitCommonDir = yield* fileSystem .realPath(resolvedGitCommonDir) .pipe(Effect.orElseSucceed(() => resolvedGitCommonDir)); @@ -1359,7 +1431,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ], { concurrency: 2 }, ); - const worktreeRootOutput = worktreeRootResult.stdout.trim(); + // Git terminates --show-toplevel with exactly one "\n" on every platform (stdout is + // binary mode on Windows). Whitespace before it belongs to the path and must survive, + // because this root becomes the editor target for every repository-relative status path. + const worktreeRootOutput = worktreeRootResult.stdout.endsWith("\n") + ? worktreeRootResult.stdout.slice(0, -1) + : worktreeRootResult.stdout; const worktreeRoot = worktreeRootResult.exitCode === 0 && worktreeRootOutput.length > 0 ? path.normalize( @@ -1368,6 +1445,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : path.resolve(cwd, worktreeRootOutput), ) : null; + // Git reports physical roots, so resolve project aliases before comparing. + const isAtRepositoryRoot = + worktreeRoot === null + ? undefined + : yield* fileSystem.realPath(cwd).pipe( + Effect.map((physicalCwd) => path.relative(worktreeRoot, physicalCwd) === ""), + Effect.orElseSucceed(() => undefined), + ); const currentBranchOutput = currentBranchResult.stdout.trim(); const currentBranch = currentBranchResult.exitCode === 0 && currentBranchOutput.length > 0 @@ -1377,6 +1462,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { gitCommonDir, worktreeRoot, + isAtRepositoryRoot, currentBranch, } satisfies GitRepositoryPaths; }); @@ -1440,35 +1526,37 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), }, ); - const remoteNamesCache = yield* Cache.makeWith( + const originExistsCache = yield* Cache.makeWith( (gitCommonDir: string) => Effect.gen(function* () { const path = yield* Path.Path; const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; - return yield* runGitStdout("GitVcsDriver.statusDetails.remoteNames", fetchCwd, [ - "--git-dir", - gitCommonDir, - "remote", - ]).pipe(Effect.map(parseRemoteNamesInGitOrder)); + return yield* executeGit( + "GitVcsDriver.statusDetails.originExists", + fetchCwd, + ["--git-dir", gitCommonDir, "remote", "get-url", "origin"], + { allowNonZeroExit: true }, + ).pipe(Effect.map((result) => result.exitCode === 0)); }), { capacity: 2_048, timeToLive: Exit.match({ - onSuccess: () => STATUS_REMOTE_NAMES_CACHE_TTL, + onSuccess: () => STATUS_ORIGIN_EXISTS_CACHE_TTL, onFailure: () => Duration.zero, }), }, ); - const invalidateStatusStaticCaches = (cwd: string) => - Effect.gen(function* () { + const invalidateStatusStaticCaches = Effect.fn("GitVcsDriverCore.invalidateStatusStaticCaches")( + function* (cwd: string) { const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), ); const cacheKey = repositoryPaths?.gitCommonDir ?? normalizeRepositoryPathsCacheKey(cwd); yield* Cache.invalidate(defaultBranchCache, cacheKey); - yield* Cache.invalidate(remoteNamesCache, cacheKey); - }); + yield* Cache.invalidate(originExistsCache, cacheKey); + }, + ); const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { const repositoryPaths = yield* resolveRepositoryPaths(cwd); @@ -1485,31 +1573,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); }); - const statusRemoteRefreshFailureCounts = new Map(); + const statusRemoteRefreshFailures = new Map(); const statusRemoteRefreshFailureKey = (cacheKey: StatusRemoteRefreshCacheKey) => `${cacheKey.gitCommonDir}\0${cacheKey.remoteName}`; - const recordStatusRemoteRefreshFailure = (cacheKey: StatusRemoteRefreshCacheKey) => { - const key = statusRemoteRefreshFailureKey(cacheKey); - const nextCount = (statusRemoteRefreshFailureCounts.get(key) ?? 0) + 1; - statusRemoteRefreshFailureCounts.delete(key); - statusRemoteRefreshFailureCounts.set(key, nextCount); - if (statusRemoteRefreshFailureCounts.size > STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY) { - const oldestKey = statusRemoteRefreshFailureCounts.keys().next().value; + const recordStatusRemoteRefreshFailure = Effect.fn( + "GitVcsDriver.recordStatusRemoteRefreshFailure", + )(function* (key: string) { + const nextCount = (statusRemoteRefreshFailures.get(key)?.count ?? 0) + 1; + const now = yield* Clock.currentTimeMillis; + statusRemoteRefreshFailures.delete(key); + statusRemoteRefreshFailures.set(key, { + count: nextCount, + retryAt: now + Duration.toMillis(statusUpstreamRefreshFailureCooldown(nextCount)), + }); + if (statusRemoteRefreshFailures.size > STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY) { + const oldestKey = statusRemoteRefreshFailures.keys().next().value; if (oldestKey !== undefined) { - statusRemoteRefreshFailureCounts.delete(oldestKey); + statusRemoteRefreshFailures.delete(oldestKey); } } - }; - const clearStatusRemoteRefreshFailures = (cacheKey: StatusRemoteRefreshCacheKey) => { - statusRemoteRefreshFailureCounts.delete(statusRemoteRefreshFailureKey(cacheKey)); - }; + }); const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* ( cacheKey: StatusRemoteRefreshCacheKey, ) { return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe( - Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))), - Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))), - Effect.tapCause((cause) => Effect.logWarning("Background Git fetch failed", cause)), Effect.as(true as const), ); }); @@ -1524,7 +1611,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : statusUpstreamRefreshFailureCooldown( - statusRemoteRefreshFailureCounts.get(statusRemoteRefreshFailureKey(cacheKey)) ?? 1, + statusRemoteRefreshFailures.get(statusRemoteRefreshFailureKey(cacheKey))?.count ?? 1, ), }); @@ -1678,49 +1765,95 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return remoteName; }); - const resolveRemoteBaseRef = Effect.fn("resolveRemoteBaseRef")(function* ( + const resolveComparisonBaseRef = Effect.fn("resolveComparisonBaseRef")(function* ( cwd: string, baseRef: string, ) { - if (!baseRef.includes("/") || baseRef.startsWith("refs/")) return null; - const comparisonRef = `refs/remotes/${baseRef}`; - const remoteBase = yield* executeGit( - "GitVcsDriver.resolveRemoteBaseRef", + const comparisonRef = + baseRef.startsWith("refs/heads/") || baseRef.startsWith("refs/remotes/") + ? baseRef + : baseRef.includes("/") && !baseRef.startsWith("refs/") + ? `refs/remotes/${baseRef}` + : null; + if (comparisonRef === null) return null; + const resolved = yield* executeGit( + "GitVcsDriver.resolveComparisonBaseRef", cwd, ["show-ref", "--verify", "--quiet", comparisonRef], { allowNonZeroExit: true }, ); - return remoteBase.exitCode === 0 ? { baseRef, comparisonRef } : null; + return resolved.exitCode === 0 ? { baseRef, comparisonRef } : null; }); - const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( + // Tracking may point to a different remote than the primary one. Resolve its + // full ref so a similarly named local branch or tag cannot shadow it. + const resolveTrackedRemoteBaseRef = Effect.fn("resolveTrackedRemoteBaseRef")(function* ( cwd: string, refName: string, - options?: { readonly allowRemoteOfCurrent?: boolean }, ) { - const configuredBaseBranch = yield* runGitStdout( - "GitVcsDriver.resolveBaseBranchForNoUpstream.config", + const upstream = yield* executeGit( + "GitVcsDriver.resolveTrackedRemoteBaseRef", cwd, - ["config", "--get", `branch.${refName}.gh-merge-base`], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); + ["rev-parse", "--symbolic-full-name", `${refName}@{upstream}`], + { allowNonZeroExit: true }, + ); + const comparisonRef = upstream.stdout.trim(); + if (upstream.exitCode !== 0 || !comparisonRef.startsWith("refs/remotes/")) return null; + return { baseRef: comparisonRef.slice("refs/remotes/".length), comparisonRef }; + }); - // Keep the configured remote and compare its exact ref even when a local ref shadows it. - const configuredRemoteBase = yield* resolveRemoteBaseRef(cwd, configuredBaseBranch); - if (configuredRemoteBase !== null) return configuredRemoteBase; + const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( + cwd: string, + refName: string, + options?: { + readonly allowRemoteOfCurrent?: boolean; + readonly knownOriginDefaultBranch?: string | null; + }, + ) { + const configuredBaseRef = yield* readConfigValue(cwd, `branch.${refName}.t3-merge-base-ref`); + if ( + configuredBaseRef?.startsWith("refs/heads/") || + configuredBaseRef?.startsWith("refs/remotes/") + ) { + const base = yield* resolveComparisonBaseRef(cwd, configuredBaseRef); + if (base !== null) { + const shortRef = configuredBaseRef.replace(/^refs\/remotes\//, ""); + return { + ...base, + baseRef: (yield* branchExists(cwd, shortRef)) ? configuredBaseRef : shortRef, + }; + } + } + + const configuredBaseBranch = + (yield* readConfigValue(cwd, `branch.${refName}.gh-merge-base`)) ?? ""; + const configuredBase = yield* resolveComparisonBaseRef(cwd, configuredBaseBranch); + if ( + configuredBase !== null && + configuredBaseBranch.includes("/") && + !configuredBaseBranch.startsWith("refs/") && + (yield* branchExists(cwd, configuredBaseBranch)) + ) { + const comparisonRef = `refs/heads/${configuredBaseBranch}`; + return { baseRef: comparisonRef, comparisonRef }; + } + if (configuredBase !== null) return configuredBase; const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe( Effect.orElseSucceed(() => null), ); - const defaultBranch = - primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName); const candidates = [ - configuredBaseBranch.length > 0 ? configuredBaseBranch : null, - defaultBranch, - ...DEFAULT_BASE_BRANCH_CANDIDATES, + Effect.succeed(configuredBaseBranch), + primaryRemoteName === null + ? Effect.succeed(null) + : primaryRemoteName === "origin" && options?.knownOriginDefaultBranch != null + ? Effect.succeed(options.knownOriginDefaultBranch) + : resolveDefaultBranchName(cwd, primaryRemoteName), + ...DEFAULT_BASE_BRANCH_CANDIDATES.map(Effect.succeed), ]; - for (const candidate of candidates) { + for (const readCandidate of candidates) { + const candidate = yield* readCandidate; if (!candidate) { continue; } @@ -1736,19 +1869,22 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* continue; } if (normalizedCandidate === refName) { - if ( - options?.allowRemoteOfCurrent && - primaryRemoteName && - (yield* remoteBranchExists({ - cwd, - remoteName: primaryRemoteName, - refName: normalizedCandidate, - })) - ) { - return { - baseRef: `${primaryRemoteName}/${normalizedCandidate}`, - comparisonRef: `refs/remotes/${primaryRemoteName}/${normalizedCandidate}`, - }; + if (options?.allowRemoteOfCurrent) { + const trackedRemoteBase = yield* resolveTrackedRemoteBaseRef(cwd, refName); + if (trackedRemoteBase !== null) return trackedRemoteBase; + if ( + primaryRemoteName && + (yield* remoteBranchExists({ + cwd, + remoteName: primaryRemoteName, + refName: normalizedCandidate, + })) + ) { + return { + baseRef: `${primaryRemoteName}/${normalizedCandidate}`, + comparisonRef: `refs/remotes/${primaryRemoteName}/${normalizedCandidate}`, + }; + } } continue; } @@ -1778,8 +1914,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const computeAheadCountAgainstBase = Effect.fn("computeAheadCountAgainstBase")(function* ( cwd: string, refName: string, + options?: { readonly knownOriginDefaultBranch: string | null }, ) { - const base = yield* resolveBaseBranchForNoUpstream(cwd, refName); + const base = yield* resolveBaseBranchForNoUpstream(cwd, refName, options); if (!base) { return 0; } @@ -1843,8 +1980,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const branchValue = branchResult.stdout.trim(); branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null; } - const upstream = yield* resolveCurrentUpstream(cwd); - const upstreamRef = upstream?.upstreamRef ?? null; + const currentUpstreamRef = yield* readCurrentUpstreamRef(cwd); + let upstreamRef = + currentUpstreamRef === null + ? null + : (parseUpstreamRefByFirstSeparator(currentUpstreamRef)?.upstreamRef ?? null); + // Trimming a remote that starts with Unicode whitespace can leave a leading slash. + if (currentUpstreamRef?.startsWith("/")) { + upstreamRef = (yield* resolveCurrentUpstream(cwd, currentUpstreamRef))?.upstreamRef ?? null; + } let aheadCount = 0; let behindCount = 0; @@ -1877,7 +2021,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* branch && !isDefaultBranch ? upstreamRef === null ? aheadCount - : yield* computeAheadCountAgainstBase(cwd, branch).pipe(Effect.orElseSucceed(() => 0)) + : yield* computeAheadCountAgainstBase(cwd, branch, { + knownOriginDefaultBranch: defaultBranch, + }).pipe(Effect.orElseSucceed(() => 0)) : 0; return { @@ -1902,6 +2048,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "status", "--porcelain=2", "--branch", + "-z", ...(includeDivergence ? [] : ["--no-ahead-behind"]), ]; const indexResult = yield* executeGitWithStableDiagnostics( @@ -1979,48 +2126,37 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), ); const statusCacheKey = repositoryPaths?.gitCommonDir; - const [numstatStdout, defaultBranch, remoteNames] = yield* Effect.all( + const [numstatEntries, defaultBranch, hasPrimaryRemote] = yield* Effect.all( [ // Plumbing, because porcelain `git diff` rewrites the index even with // GIT_OPTIONAL_LOCKS=0. -M keeps porcelain's rename detection. executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.numstat", cwd, - ["diff-index", "-M", "--numstat", "HEAD", "--"], + ["diff-index", "-M", "--numstat", "-z", "HEAD", "--"], { allowNonZeroExit: true }, ).pipe( Effect.flatMap((result) => { - if (result.exitCode === 0) return Effect.succeed(result.stdout); + if (result.exitCode === 0) return Effect.succeed(parseReviewNumstat(result.stdout)); if (isUnbornHeadStderr(result.stderr)) { return Effect.map( Effect.all([ runGitStdout("GitVcsDriver.statusDetails.numstat.unborn", cwd, [ "diff-files", "--numstat", + "-z", ]), runGitStdout("GitVcsDriver.statusDetails.numstat.unborn.staged", cwd, [ "diff", "--cached", "--numstat", + "-z", ]), ]), - ([unstagedStdout, stagedStdout]) => { - const staged = parseNumstatEntries(stagedStdout); - const unstaged = parseNumstatEntries(unstagedStdout); - const map = new Map(); - for (const entry of [...staged, ...unstaged]) { - const existing = map.get(entry.path) ?? { - insertions: 0, - deletions: 0, - }; - existing.insertions += entry.insertions; - existing.deletions += entry.deletions; - map.set(entry.path, existing); - } - return Array.from(map.entries()) - .map(([p, s]) => `${s.insertions}\t${s.deletions}\t${p}`) - .join("\n"); - }, + ([unstagedStdout, stagedStdout]) => [ + ...parseReviewNumstat(stagedStdout), + ...parseReviewNumstat(unstagedStdout), + ], ); } return Effect.fail( @@ -2028,7 +2164,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...gitCommandContext({ operation: "GitVcsDriver.statusDetails.numstat", cwd, - args: ["diff-index", "-M", "--numstat", "HEAD", "--"], + args: ["diff-index", "-M", "--numstat", "-z", "HEAD", "--"], }), detail: "git diff-index HEAD --numstat failed.", exitCode: result.exitCode, @@ -2041,9 +2177,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* statusCacheKey ? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null)) : resolveDefaultBranchName(cwd, "origin").pipe(Effect.orElseSucceed(() => null)), - (statusCacheKey ? Cache.get(remoteNamesCache, statusCacheKey) : listRemoteNames(cwd)).pipe( - Effect.orElseSucceed((): ReadonlyArray => []), - ), + statusCacheKey + ? Cache.get(originExistsCache, statusCacheKey).pipe(Effect.orElseSucceed(() => false)) + : originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), ], { concurrency: "unbounded" }, ); @@ -2057,7 +2193,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* let hasWorkingTreeChanges = false; const changedFilesWithoutNumstat = new Set(); - for (const line of statusStdout.split(/\r?\n/g)) { + const statusRecords = statusStdout.split("\0"); + for (let index = 0; index < statusRecords.length; index++) { + const line = statusRecords[index]!; if (line.startsWith("# branch.head ")) { const value = line.slice("# branch.head ".length).trim(); refName = value.startsWith("(") ? null : value; @@ -2075,10 +2213,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* behindCount = parsed.behind; continue; } - if (line.trim().length > 0 && !line.startsWith("#")) { + if (line.length > 0 && !line.startsWith("#")) { hasWorkingTreeChanges = true; const pathValue = parsePorcelainPath(line); if (pathValue) changedFilesWithoutNumstat.add(pathValue); + // A rename record is followed by its original path as a separate record. Listing it + // keeps the deletion selectable when committing only some files. + if (line.startsWith("2 ")) { + const originalPath = statusRecords[++index]; + if (originalPath) changedFilesWithoutNumstat.add(originalPath); + } } } @@ -2103,10 +2247,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)); } - const numstatEntries = parseNumstatEntries(numstatStdout); const fileStatMap = new Map(); for (const entry of numstatEntries) { - fileStatMap.set(entry.path, { insertions: entry.insertions, deletions: entry.deletions }); + const stat = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; + stat.insertions += entry.additions; + stat.deletions += entry.deletions; + fileStatMap.set(entry.path, stat); } let insertions = 0; @@ -2133,13 +2279,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { isRepo: true, - hasOriginRemote: remoteNames.includes("origin"), - remoteNames, + hasOriginRemote: hasPrimaryRemote, isDefaultBranch, branch: refName, upstreamRef, hasWorkingTreeChanges, workingTree: { + ...(repositoryPaths?.worktreeRoot ? { rootPath: repositoryPaths.worktreeRoot } : {}), files, insertions, deletions, @@ -2190,7 +2336,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((details) => ({ isRepo: details.isRepo, hasPrimaryRemote: details.hasOriginRemote, - ...(details.remoteNames === undefined ? {} : { remoteNames: details.remoteNames }), isDefaultRef: details.isDefaultBranch, refName: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, @@ -2234,7 +2379,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* runGit( "GitVcsDriver.commit.addSelected", cwd, - [...config, "--literal-pathspecs", "add", "-A", "--", ...filePaths], + // Status paths are repository-relative, while cwd can be a subdirectory. + [...config, "add", "-A", "--", ...filePaths.map((file) => `:(top,literal)${file}`)], env ? { env } : {}, ); } else { @@ -2498,9 +2644,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); const remoteName = publishRemoteName ?? currentUpstream.remoteName; const publishBranch = yield* resolvePublishBranchName(cwd, branch); - // `-u` retargets the upstream to the published branch, so keep the - // base recorded first; base resolution reads gh-merge-base before the - // upstream ref. + // Publishing retargets the upstream, so preserve its comparison base first. const configuredMergeBase = yield* runGitStdout( "GitVcsDriver.pushCurrentBranch.readMergeBase", cwd, @@ -2513,6 +2657,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `branch.${branch}.gh-merge-base`, currentUpstream.branchName, ]); + if (!(yield* readConfigValue(cwd, `branch.${branch}.t3-merge-base-ref`))) { + yield* runGit("GitVcsDriver.pushCurrentBranch.recordComparisonRef", cwd, [ + "config", + `branch.${branch}.t3-merge-base-ref`, + `refs/remotes/${currentUpstream.upstreamRef}`, + ]); + } } yield* runGit( "GitVcsDriver.pushCurrentBranch.pushOwnBranch", @@ -2554,7 +2705,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const pullCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pullCurrentBranch"] = Effect.fn( "pullCurrentBranch", )(function* (cwd) { - const details = yield* statusDetails(cwd); + const details = yield* readStatusDetailsLocal(cwd); const refName = details.branch; if (!refName) { return yield* new GitCommandError({ @@ -2576,6 +2727,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* detail: "Current branch has no upstream configured. Push with upstream first.", }); } + const remote = yield* readConfigValue(cwd, `branch.${refName}.remote`); + if (remote) { + const gitCommonDir = yield* resolveGitCommonDir(cwd); + const pending = statusFetches.get(`${gitCommonDir}\0${remote}`); + // The status wait can finish while its fetch still owns reference locks. + // Let our existing writer finish before starting the pull's fetch. + if (pending) yield* Fiber.await(pending); + } const beforeSha = yield* runGitStdout( "GitVcsDriver.pullCurrentBranch.beforeSha", cwd, @@ -2585,11 +2744,67 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // After a successful pull, HEAD before and after decides the result, so its output is not // needed. A large fast-forward's diffstat can exceed the output cap, which must not fail a // pull Git applied. - yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { + const options = { timeoutMs: 30_000, - fallbackErrorDetail: "git pull failed", appendTruncationMarker: true, - }); + allowNonZeroExit: true, + }; + let args = ["pull", "--ff-only"]; + let result = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.pullCurrentBranch.pull", + cwd, + args, + options, + ); + if (result.exitCode !== 0 && isRefLockFailure(result.stderr)) { + const [remote, mergeRefs, currentBranch, currentSha] = yield* Effect.all([ + readConfigValue(cwd, `branch.${refName}.remote`), + runGitStdout( + "GitVcsDriver.pullCurrentBranch.mergeRefs", + cwd, + ["config", "--get-all", `branch.${refName}.merge`], + true, + ).pipe(Effect.map((stdout) => stdout.trim().split("\n").filter(Boolean))), + runGitStdout( + "GitVcsDriver.pullCurrentBranch.currentBranch", + cwd, + ["symbolic-ref", "--quiet", "--short", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())), + runGitStdout("GitVcsDriver.pullCurrentBranch.currentSha", cwd, ["rev-parse", "HEAD"]).pipe( + Effect.map((stdout) => stdout.trim()), + ), + ]); + // A full fetch can fail on an unrelated branch's lock. Keep that fetch as the + // default, but retry only the configured upstream before any checkout movement. + const [mergeRef] = mergeRefs; + if ( + remote && + mergeRef && + mergeRefs.length === 1 && + currentBranch === refName && + currentSha === beforeSha + ) { + args = ["pull", "--ff-only", "--", remote, mergeRef]; + result = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.pullCurrentBranch.pull", + cwd, + args, + options, + ); + } + } + if (result.exitCode !== 0) { + const reason = classifyGitFailure(result.stderr); + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.pullCurrentBranch.pull", cwd, args }), + ...(reason === null ? {} : { reason }), + detail: fetchFailureDetail(result.stderr) ?? "git pull failed", + exitCode: result.exitCode, + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }); + } const afterSha = yield* runGitStdout( "GitVcsDriver.pullCurrentBranch.afterSha", cwd, @@ -2597,7 +2812,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* true, ).pipe(Effect.map((stdout) => stdout.trim())); - const refreshed = yield* statusDetails(cwd); + const refreshed = yield* readStatusDetailsLocal(cwd); return { status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", refName, @@ -2712,6 +2927,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd, [ ...tempIndexConfig, + // Avoid redundant tracked-entry pathspec matching in this disposable index. + "-c", + "core.preloadIndex=false", "--literal-pathspecs", "add", "--intent-to-add", @@ -2779,7 +2997,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ) { const base = explicitBaseRef !== undefined - ? ((yield* resolveRemoteBaseRef(cwd, explicitBaseRef)) ?? { + ? ((yield* resolveComparisonBaseRef(cwd, explicitBaseRef)) ?? { baseRef: explicitBaseRef, comparisonRef: !explicitBaseRef.startsWith("refs/") && (yield* branchExists(cwd, explicitBaseRef)) @@ -2898,6 +3116,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const diffArgs = [ ...REVIEW_DIFF_ARGS, + "--minimal", ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), ]; const readStats = Effect.fn("GitVcsDriver.getReviewDiffPreview.stat")(function* ( @@ -3157,7 +3376,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); - const resolveBranchName = Effect.fn("GitVcsDriver.resolveBranchName")(function* ( + const resolveBranchBase = Effect.fn("GitVcsDriver.resolveBranchBase")(function* ( cwd: string, refName: string, ) { @@ -3172,19 +3391,29 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.orElseSucceed(() => null), ); return remoteName && (yield* remoteBranchExists({ cwd, remoteName, refName })) - ? refName + ? { baseRef: `refs/remotes/${remoteName}/${refName}`, branchName: refName } : null; } const ref = result.stdout.trim(); - if (ref.startsWith("refs/heads/")) return ref.slice("refs/heads/".length); + if (ref.startsWith("refs/heads/")) { + const branchName = ref.slice("refs/heads/".length); + return { baseRef: ref, branchName }; + } if (!ref.startsWith("refs/remotes/")) return null; const remoteNames = yield* listRemoteNames(cwd); - return ( - parseRemoteRefWithRemoteNames( - ref.slice("refs/remotes/".length), - remoteNames.toSorted((left, right) => right.length - left.length), - )?.branchName ?? null + const remoteRef = parseRemoteRefWithRemoteNames( + ref.slice("refs/remotes/".length), + remoteNames.toSorted((left, right) => right.length - left.length), ); + return remoteRef ? { baseRef: ref, branchName: remoteRef.branchName } : null; + }); + + const resolveBranchName = Effect.fn("GitVcsDriver.resolveBranchName")(function* ( + cwd: string, + refName: string, + ) { + const base = yield* resolveBranchBase(cwd, refName); + return base?.branchName ?? null; }); const readGitRefsSnapshot = Effect.fn("readGitRefsSnapshot")(function* (gitCommonDir: string) { @@ -3199,7 +3428,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* [ ...gitDirArgs, "for-each-ref", - "--format=%(refname)%09%(committerdate:unix)%09%(symref)", + "--format=%(refname)%09%(committerdate:unix)%09%(symref)%09%(upstream)", "refs/heads", "refs/remotes", ], @@ -3249,13 +3478,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* [branchName, path.normalize(path.resolve(worktreePath))] as const, ) : []; + // Without `core.worktree`, Git reports a separate git dir as the main checkout. + const normalizedGitCommonDir = path.normalize(path.resolve(gitCommonDir)); const existingWorktreeEntries = yield* Effect.filter( parsedWorktreeEntries, ([, worktreePath]) => - fileSystem.stat(worktreePath).pipe( - Effect.as(true), - Effect.orElseSucceed(() => false), - ), + worktreePath === normalizedGitCommonDir + ? Effect.succeed(false) + : fileSystem.stat(worktreePath).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ), { concurrency: 16 }, ); const worktreeMap = new Map(existingWorktreeEntries); @@ -3264,7 +3497,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* for (const line of refsResult.stdout.split("\n")) { if (line.length === 0) continue; - const [fullRefName, lastCommitRaw, symbolicTarget] = line.split("\t"); + const [fullRefName, lastCommitRaw, symbolicTarget, upstreamRef] = line.split("\t"); if (!fullRefName || symbolicTarget) continue; const parsedLastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); const lastCommit = Number.isFinite(parsedLastCommit) ? parsedLastCommit : 0; @@ -3278,6 +3511,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* isRemote: false, isDefault: name === defaultBranch, worktreePath: worktreeMap.get(name) ?? null, + ...(upstreamRef ? { upstreamRef } : {}), }, lastCommit, }); @@ -3446,6 +3680,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* snapshot.localBranches.some((ref) => ref.worktreePath === repositoryPaths.worktreeRoot); const localBranches = snapshot.localBranches.map((ref) => ({ ...ref, + worktreePath: + ref.worktreePath ?? + (ref.name === repositoryPaths.currentBranch ? repositoryPaths.worktreeRoot : null), current: hasCurrentWorktreeBranch ? ref.worktreePath === repositoryPaths.worktreeRoot : ref.name === repositoryPaths.currentBranch, @@ -3471,6 +3708,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); return { + ...(repositoryPaths.isAtRepositoryRoot === undefined + ? {} + : { isAtRepositoryRoot: repositoryPaths.isAtRepositoryRoot }), refs: [...refs.refs], isRepo: true, hasPrimaryRemote: snapshot.hasPrimaryRemote, @@ -3514,7 +3754,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const onCheckoutProgress = progress?.onCheckoutProgress; const checkoutWorkers = (yield* readConfigValue(input.cwd, "checkout.workers")) ?? "0"; - yield* executeGit( + yield* executeGitWithStableDiagnostics( "GitVcsDriver.createWorktree", input.cwd, ["-c", `checkout.workers=${checkoutWorkers}`, ...args], @@ -3525,7 +3765,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? { // Git only prints checkout progress when stderr is a tty or the // delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe. - env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" }, + env: { GIT_PROGRESS_DELAY: "0" }, progress: { onStderrLine: (line) => { const parsed = parseGitCheckoutProgressLine(line); @@ -3610,13 +3850,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } if (input.newRefName && input.baseRefName) { - const baseBranch = yield* resolveBranchName(input.cwd, input.baseRefName); - if (baseBranch) { - yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ - "config", - `branch.${input.newRefName}.gh-merge-base`, - baseBranch, - ]); + const base = yield* resolveBranchBase(input.cwd, input.baseRefName); + if (base) { + yield* Effect.gen(function* () { + yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ + "config", + `branch.${input.newRefName}.gh-merge-base`, + base.branchName, + ]); + if (base.baseRef.startsWith("refs/remotes/")) { + // gh consumes its key as a provider branch; T3 also needs the selected remote. + yield* runGit("GitVcsDriver.createWorktree.configureComparisonRef", input.cwd, [ + "config", + `branch.${input.newRefName}.t3-merge-base-ref`, + base.baseRef, + ]); + } + }).pipe( + Effect.catchTags({ + GitCommandError: () => + Effect.logWarning( + "worktree base-ref configuration failed; using base-branch fallback", + ), + }), + ); } } @@ -3737,84 +3994,78 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { headCommit: input.targetCommit, moved: true, onTarget: true }; }); - const fetchRemote: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = Effect.fn("fetchRemote")( - function* (input) { - const args = ["fetch", "--quiet", "--", input.remoteName]; - const options = { - env: STATUS_UPSTREAM_REFRESH_ENV, - fallbackErrorDetail: `git fetch ${input.remoteName} failed`, - }; - // Another fetch in the same repository, such as the background status - // poll, can win the race for a remote-tracking ref. The loser fails, but - // the ref is free again by the time a rerun needs it, so retry once. - const runFetch = (fetchArgs: readonly string[]) => { - const run = executeGitWithStableDiagnostics( - "GitVcsDriver.fetchRemote", - input.cwd, - fetchArgs, - { - ...options, - allowNonZeroExit: true, - }, - ); - return run.pipe( - Effect.flatMap((result) => - result.exitCode !== 0 && isRefLockFailure(result.stderr) ? run : Effect.succeed(result), - ), - ); - }; - const fetchAll = runFetch(args).pipe( + const fetchRemote = Effect.fn("fetchRemote")(function* (input: GitVcsDriver.GitFetchRemoteInput) { + const args = ["fetch", "--quiet", "--", input.remoteName]; + const options = { + env: STATUS_UPSTREAM_REFRESH_ENV, + fallbackErrorDetail: `git fetch ${input.remoteName} failed`, + }; + // Another fetch in the same repository, such as the background status + // poll, can win the race for a remote-tracking ref. The loser fails, but + // the ref is free again by the time a rerun needs it, so retry once. + const runFetch = (fetchArgs: readonly string[]) => { + const run = executeGitWithStableDiagnostics( + "GitVcsDriver.fetchRemote", + input.cwd, + fetchArgs, + { + ...options, + allowNonZeroExit: true, + }, + ); + return run.pipe( Effect.flatMap((result) => - result.exitCode === 0 - ? Effect.void - : Effect.fail( - new GitCommandError({ - ...gitCommandContext({ - operation: "GitVcsDriver.fetchRemote", - cwd: input.cwd, - args, - }), - detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail, - ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - }), - ), + result.exitCode !== 0 && isRefLockFailure(result.stderr) ? run : Effect.succeed(result), ), ); - if (input.refName === undefined) { - return yield* fetchAll.pipe(Effect.asVoid); - } - const branch = - parseRemoteRefWithRemoteNames(input.refName, [input.remoteName])?.branchName ?? - input.refName; - const scopedArgs = [ - ...args, - `+refs/heads/${branch}:refs/remotes/${input.remoteName}/${branch}`, - ]; - const result = yield* runFetch(scopedArgs); - if (result.exitCode === 0) return; - const missingBranch = result.stderr - .split(/\r?\n/) - .includes(`fatal: couldn't find remote ref refs/heads/${branch}`); - if (missingBranch && input.requireBranch !== true) { - return yield* fetchAll.pipe(Effect.asVoid); - } - return yield* new GitCommandError({ - ...gitCommandContext({ - operation: "GitVcsDriver.fetchRemote", - cwd: input.cwd, - args: scopedArgs, - }), - detail: missingBranch - ? `${input.remoteName}/${branch} was not found.` - : (fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail), - ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - }); - }, - ); + }; + const fetchAll = runFetch(args).pipe( + Effect.flatMap((result) => + result.exitCode === 0 + ? Effect.void + : Effect.fail( + new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.fetchRemote", + cwd: input.cwd, + args, + }), + detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail, + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + ), + ); + if (input.refName === undefined) { + return yield* fetchAll.pipe(Effect.as(true)); + } + const branch = + parseRemoteRefWithRemoteNames(input.refName, [input.remoteName])?.branchName ?? input.refName; + const scopedArgs = [ + ...args, + `+refs/heads/${branch}:refs/remotes/${input.remoteName}/${branch}`, + ]; + const result = yield* runFetch(scopedArgs); + if (result.exitCode === 0) return true; + if ( + result.stderr.split(/\r?\n/).includes(`fatal: couldn't find remote ref refs/heads/${branch}`) + ) { + return false; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.fetchRemote", + cwd: input.cwd, + args: scopedArgs, + }), + detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail, + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }); + }); const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveRemoteTrackingCommit"] = Effect.fn("resolveRemoteTrackingCommit")(function* (input) { @@ -3834,6 +4085,62 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { commitSha, remoteRefName }; }); + const resolveWorktreeBaseRemote: GitVcsDriver.GitVcsDriver["Service"]["resolveWorktreeBaseRemote"] = + Effect.fn("resolveWorktreeBaseRemote")(function* (input) { + const remoteNames = (yield* listRemoteNames(input.cwd)).toSorted( + (left, right) => right.length - left.length, + ); + // Upstream atoms read config even before the tracking ref has been fetched. + // Match the exact local ref: for-each-ref also returns its descendants. + const localBranch = yield* runGitStdout("GitVcsDriver.resolveWorktreeBaseRemote", input.cwd, [ + "for-each-ref", + "--format=%(refname)\t%(upstream:remotename)\t%(upstream:remoteref)", + `refs/heads/${input.refName}`, + ]).pipe( + Effect.map((stdout) => + stdout + .split("\n") + .map((line) => line.split("\t")) + .find(([refName]) => refName === `refs/heads/${input.refName}`), + ), + ); + let target: { remoteName: string; branchName: string } | null; + if (localBranch) { + // A remote of "." tracks another local branch; it is not a remote name. + const [, remoteName = "", merge = ""] = localBranch; + target = + remoteNames.includes(remoteName) && merge.startsWith("refs/heads/") + ? { remoteName, branchName: merge.slice("refs/heads/".length) } + : null; + } else { + target = parseRemoteRefWithRemoteNames(input.refName, remoteNames); + } + const { remoteName, branchName } = target ?? { + remoteName: "origin", + branchName: input.refName, + }; + return remoteNames.includes(remoteName) ? { remoteName, branchName } : null; + }); + + const fetchWorktreeBaseCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchWorktreeBaseCommit"] = + Effect.fn("fetchWorktreeBaseCommit")(function* (input) { + const target = yield* resolveWorktreeBaseRemote(input); + if (target === null) return null; + const { remoteName, branchName } = target; + const remoteRefName = `${remoteName}/${branchName}`; + // A missing remote branch can leave a stale tracking ref; never resolve it. + if (!(yield* fetchRemote({ cwd: input.cwd, remoteName, refName: remoteRefName }))) { + return null; + } + const resolved = yield* executeGit( + "GitVcsDriver.fetchWorktreeBaseCommit", + input.cwd, + ["rev-parse", "--verify", "--quiet", `refs/remotes/${remoteRefName}^{commit}`], + { allowNonZeroExit: true }, + ); + return resolved.exitCode === 0 ? { commitSha: resolved.stdout.trim(), remoteRefName } : null; + }); + const fetchRemoteBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"] = Effect.fn( "fetchRemoteBranch", )(function* (input) { @@ -3878,26 +4185,72 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.branch, ]); + /** + * Unlinks links under `target` without following them and removes the + * directories that leaves empty. Returns false when anything else remains. + */ + // Recursive filesystem traversal can visit many entries; removeWorktree owns the operation span. + const removeLeftoverLinks = Effect.fnUntraced(function* ( + target: string, + ): Effect.fn.Return { + if (Option.isSome(yield* fileSystem.readLink(target).pipe(Effect.option))) { + yield* fileSystem.remove(target); + return true; + } + if ((yield* fileSystem.stat(target)).type !== "Directory") return false; + let empty = true; + for (const name of yield* fileSystem.readDirectory(target)) { + if (!(yield* removeLeftoverLinks(path.join(target, name)))) empty = false; + } + // Only an empty directory goes, so files written since the listing stay. + return empty && (yield* removeEmptyDirectory(target)); + }); + const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn( "removeWorktree", )(function* (input) { - // Git refuses to remove a worktree with untracked files unless forced, but - // its check honors `status.showUntrackedFiles=no` and would delete them. - const args = ["-c", "status.showUntrackedFiles=normal", "worktree", "remove"]; - if (input.force) { - args.push("--force"); - } - args.push("--", input.path); - const result = yield* executeGitWithStableDiagnostics( - "GitVcsDriver.removeWorktree", - input.cwd, - args, - { - timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS, - allowNonZeroExit: true, - }, + // Git also matches a worktree by its short name, which can name a different + // folder than this path. Resolving it once keeps Git, the Windows deletion + // in removeLinkedWorktree, and the leftover cleanup below on one directory. + const target = path.resolve(input.cwd, input.path); + const result = yield* removeLinkedWorktree({ + operation: "GitVcsDriver.removeWorktree", + cwd: input.cwd, + worktreePath: target, + force: input.force === true, + execute, + run: (args) => + executeGitWithStableDiagnostics("GitVcsDriver.removeWorktree", input.cwd, args, { + timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS, + allowNonZeroExit: true, + }).pipe(Effect.map((result) => ({ ...result, args }))), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), ); if (result.exitCode === 0) { + // Git for Windows never descends into NTFS junctions, such as pnpm's + // node_modules links. It reports success but leaves them and their parent + // directories behind, so a resumed thread would find a stub instead of + // recreating its checkout. Git has already unregistered the worktree, so + // anything left here is logged rather than returned: a retry could never + // succeed. + if (yield* fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false))) { + const removed = yield* removeLeftoverLinks(target).pipe( + Effect.catch((error) => + Effect.logWarning("GitVcsDriver.removeWorktree: failed to delete leftover links", { + path: target, + error, + }).pipe(Effect.as(true)), + ), + ); + if (!removed) { + yield* Effect.logWarning( + "GitVcsDriver.removeWorktree: kept files written after git removed the worktree", + { path: target }, + ); + } + } return; } // Threads can share a worktree path, and worktrees get removed or pruned @@ -3905,7 +4258,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // than an error. const alreadyGone = isMissingWorktreeStderr(result.stderr) && - !(yield* fileSystem.exists(input.path).pipe(Effect.orElseSucceed(() => false))); + !(yield* fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false))); if (alreadyGone) { yield* pruneWorktrees({ cwd: input.cwd }); return; @@ -3914,7 +4267,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `GitVcsDriver.removeWorktree: git worktree remove exited with code ${result.exitCode} for ${input.path} (stderr length ${result.stderr.length}).`, ); return yield* new GitCommandError({ - ...gitCommandContext({ operation: "GitVcsDriver.removeWorktree", cwd: input.cwd, args }), + ...gitCommandContext({ + operation: "GitVcsDriver.removeWorktree", + cwd: input.cwd, + args: result.args, + }), detail: "git worktree remove failed", ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), stdoutLength: result.stdout.length, @@ -4040,10 +4397,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* refuseOptionLikeRefName("GitVcsDriver.switchRef", input.cwd, checkoutArgs.at(-1)); // A stale ref must not turn into a path checkout that discards local edits. - yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, [...checkoutArgs, "--"], { - timeoutMs: 10_000, - fallbackErrorDetail: "git checkout failed", - }); + yield* executeGitWithStableDiagnostics( + "GitVcsDriver.switchRef.checkout", + input.cwd, + [...checkoutArgs, "--"], + { + timeoutMs: 10_000, + fallbackErrorDetail: "git checkout failed", + }, + ); const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ "branch", @@ -4165,10 +4527,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), resolvePrimaryRemoteName, resolveDefaultBranchName, - fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), + fetchRemote: (input) => + withListRefsInvalidation( + input.cwd, + fetchRemote(input).pipe( + Effect.flatMap((fetched) => + fetched + ? Effect.void + : fetchRemote({ cwd: input.cwd, remoteName: input.remoteName }).pipe(Effect.asVoid), + ), + ), + ), remoteExists, remoteBranchExists, resolveRemoteTrackingCommit, + resolveWorktreeBaseRemote, + fetchWorktreeBaseCommit: (input) => + withListRefsInvalidation(input.cwd, fetchWorktreeBaseCommit(input)), fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), fetchRemoteTrackingBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)), diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index 73f75ec42b4a..4ecc5237da20 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -25,6 +25,8 @@ export interface VcsRestoreCheckpointInput { readonly fallbackToHead?: boolean; } +export const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000; + export interface VcsDiffCheckpointsInput { readonly cwd: string; readonly fromCheckpointRef: CheckpointRef; @@ -32,6 +34,8 @@ export interface VcsDiffCheckpointsInput { readonly fallbackFromToHead?: boolean; readonly ignoreWhitespace: boolean; readonly format?: "patch" | "numstat"; + /** Maximum bytes collected from the diff command. */ + readonly maxOutputBytes?: number; /** An empty list yields an empty diff. */ readonly filePaths?: ReadonlyArray; } diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index 1e1ed5254dec..e5c816977483 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -22,29 +22,6 @@ const normalizeGitArgs = (args: ReadonlyArray): ReadonlyArray => args[0] === "-C" && args.length >= 2 ? args.slice(2) : args; describe("VcsDriverRegistry", () => { - it.effect("routes directly by VCS driver kind for non-repository workflows", () => { - const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( - Layer.provide(NodeServices.layer), - Layer.provide( - Layer.mock(VcsProjectConfig.VcsProjectConfig)({ - resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), - }), - ), - Layer.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: () => Effect.succeed(processOutput("")), - }), - ), - ); - - return Effect.gen(function* () { - const registry = yield* VcsDriverRegistry.VcsDriverRegistry; - const driver = yield* registry.get("git"); - - assert.strictEqual(driver.capabilities.kind, "git"); - }).pipe(Effect.provide(layer)); - }); - const makeDiskBackedLayer = (rootDir: string, calls: string[]) => Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( Layer.provide(NodeServices.layer), @@ -83,30 +60,6 @@ describe("VcsDriverRegistry", () => { ), ); - it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const repoDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" }); - yield* fs.makeDirectory(path.join(repoDir, ".git")); - const calls: string[] = []; - - yield* Effect.gen(function* () { - const registry = yield* VcsDriverRegistry.VcsDriverRegistry; - const first = yield* registry.resolve({ cwd: repoDir, requestedKind: "git" }); - const second = yield* registry.resolve({ cwd: repoDir, requestedKind: "git" }); - - assert.equal(first.repository.rootPath, repoDir); - assert.equal(second.repository.rootPath, repoDir); - assert.deepStrictEqual(calls, [ - "rev-parse --is-inside-work-tree", - "rev-parse --show-toplevel", - "rev-parse --git-common-dir", - ]); - }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); - }).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("detects again when a cached repository is removed from disk", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -151,28 +104,6 @@ describe("VcsDriverRegistry", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("detects again when git init creates a repository between the cwd and its root", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" }); - const middleDir = path.join(parentDir, "a"); - const cwd = path.join(middleDir, "b"); - yield* fs.makeDirectory(path.join(parentDir, ".git")); - yield* fs.makeDirectory(cwd, { recursive: true }); - const calls: string[] = []; - - yield* Effect.gen(function* () { - const registry = yield* VcsDriverRegistry.VcsDriverRegistry; - assert.equal((yield* registry.detect({ cwd }))?.repository.rootPath, parentDir); - - yield* fs.makeDirectory(path.join(middleDir, ".git")); - - assert.equal((yield* registry.detect({ cwd }))?.repository.rootPath, middleDir); - }).pipe(Effect.provide(makeDiskBackedLayer(parentDir, calls))); - }).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("detects a repository created after a negative lookup", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index e5fcd758cc45..c781bd5c6da8 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -1,7 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -13,14 +12,9 @@ import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/process"; -import { - VcsProcessExitError, - VcsProcessSpawnError, - VcsProcessTimeoutError, -} from "@t3tools/contracts"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as ProcessRunner from "../processRunner.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -57,8 +51,63 @@ const captureProcessResult = ( ); describe("VcsProcess.run", () => { + it.effect("explains a locked Git index and allows restore after the lock is released", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-index-lock-" }); + const git = (args: readonly string[]) => + run({ operation: "test.checkpoint.restore", command: "git", cwd, args }); + yield* git(["init"]); + const file = path.join(cwd, "README.md"); + yield* fs.writeFileString(file, "before\n"); + yield* git(["add", "README.md"]); + yield* git([ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "initial", + ]); + yield* fs.writeFileString(file, "after\n"); + const lock = path.join(cwd, ".git", "index.lock"); + yield* fs.writeFileString(lock, "held by another process\n"); + const restore = ["restore", "--source=HEAD", "--worktree", "--staged", "--", "README.md"]; + + const error = yield* Effect.flip(git(restore)); + expect(error).toBeInstanceOf(VcsProcessExitError); + expect(error).toMatchObject({ + exitCode: 128, + failureKind: "command-failed", + detail: "Git's index is locked. Wait for other Git operations to finish, then try again.", + }); + expect(error.message).not.toContain("index.lock"); + expect(yield* fs.readFileString(file)).toBe("after\n"); + expect(yield* fs.readFileString(lock)).toBe("held by another process\n"); + + yield* fs.remove(lock); + yield* git(restore); + expect(yield* fs.readFileString(file)).toBe("before\n"); + }).pipe(provideLive, Effect.provide(NodeServices.layer), Effect.scoped), + ); + it.effect.each([ - { stderr: "fatal: Unable to create '/private/repo/index.lock': File exists", retryable: true }, + { + stderr: "fatal: Unable to create '/private/repo/index.lock': File exists", + retryable: true, + detail: "Git's index is locked. Wait for other Git operations to finish, then try again.", + }, + { + stderr: "fatal: Unable to create 'C:\\private\\repo\\.git\\index.lock': File exists.", + retryable: true, + detail: "Git's index is locked. Wait for other Git operations to finish, then try again.", + }, + { + stderr: "fatal: Unable to create '/private/repo/.git/config.lock': File exists.", + retryable: true, + }, { stderr: "fatal: Unable to create '/private/repo/index.lock': Permission denied", retryable: false, @@ -75,8 +124,8 @@ describe("VcsProcess.run", () => { }, { stderr: "fatal: index file corrupt", retryable: false }, ])( - "classifies Git exit retryability without retaining stderr: $stderr", - ({ stderr, retryable }) => + "classifies Git exit retryability and safe detail without retaining stderr: $stderr", + ({ stderr, retryable, detail = "Process exited with a non-zero status." }) => Effect.gen(function* () { const error = yield* captureProcessResult( Effect.succeed({ @@ -92,8 +141,9 @@ describe("VcsProcess.run", () => { ); assert.instanceOf(error, VcsProcessExitError); expect(error.retryable === true).toBe(retryable); + expect(error.detail).toBe(detail); const encoded = yield* encodeExitError(error); - expect(encoded).not.toContain("/private/repo"); + expect(encoded).not.toContain("private"); }), ); @@ -128,8 +178,17 @@ describe("VcsProcess.run", () => { stderr: "fatal: fsync error on '.git/objects/a5/tmp_obj_Pseg66': Operation not supported", fsyncMethods: ["fsync"], }, + { + stderr: "fatal: fsync error on '.git/objects/bulk_fsync_Pseg66': Operation not supported", + fsyncMethods: ["batch", "writeout-only"], + }, + { + stderr: + "fatal: update_ref failed for ref 'refs/t3/checkpoints/x': cannot update ref 'refs/t3/checkpoints/x': couldn't write '/Volumes/share/.git/refs/t3/checkpoints/x.lock'", + fsyncMethods: ["batch", "writeout-only"], + }, ] as const)( - "falls back to writeout-only only for unsupported fsync on macOS: $stderr", + "falls back to writeout-only only for unsupported flushes on macOS ($fsyncMethods): $stderr", ({ stderr, fsyncMethods, ...scenario }) => Effect.gen(function* () { const seen: Array> = []; @@ -143,7 +202,7 @@ describe("VcsProcess.run", () => { Effect.sync(() => { seen.push(input.args); const failed = - input.args.includes("core.fsyncMethod=fsync") || + input.args.includes(`core.fsyncMethod=${fsyncMethods[0]}`) || ("fallbackFails" in scenario && scenario.fallbackFails); return { stdout: "", @@ -165,7 +224,14 @@ describe("VcsProcess.run", () => { "operation" in scenario ? scenario.operation : VcsProcess.CHECKPOINT_CAPTURE_OPERATION, - args: ["-c", "core.fsync=objects,reference", "-c", "core.fsyncMethod=fsync", "add"], + args: [ + "-c", + "core.fsync=objects,reference", + "-c", + ...(fsyncMethods[0] === "batch" ? ["core.fsyncMethod=fsync", "-c"] : []), + `core.fsyncMethod=${fsyncMethods[0]}`, + "add", + ], }) .pipe(Effect.exit); expect(seen).toEqual( @@ -173,6 +239,9 @@ describe("VcsProcess.run", () => { "-c", "core.fsync=objects,reference", "-c", + ...(fsyncMethods[0] === "batch" + ? [`core.fsyncMethod=${method === "batch" ? "fsync" : method}`, "-c"] + : []), `core.fsyncMethod=${method}`, "add", ]), @@ -189,130 +258,6 @@ describe("VcsProcess.run", () => { }), ); - it.effect.each(["timeout", "spawn"] as const)( - "checkpoint commands do not retry %s failures", - (kind) => - Effect.gen(function* () { - let attempts = 0; - const fields = { command: "git", argumentCount: 2, cwd: "/workspace" }; - const failure = - kind === "timeout" - ? new ProcessRunner.ProcessTimeoutError({ ...fields, timeoutMs: 30_000 }) - : new ProcessRunner.ProcessSpawnError({ ...fields, cause: new Error("spawn failed") }); - const service = yield* VcsProcess.make.pipe( - Effect.provideService(ProcessRunner.ProcessRunner, { - run: () => - Effect.sync(() => { - attempts += 1; - }).pipe(Effect.andThen(Effect.fail(failure))), - }), - ); - const error = yield* service - .run({ - ...baseInput, - operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION, - }) - .pipe(Effect.flip); - expect(attempts).toBe(1); - expect(error._tag).toBe( - kind === "timeout" ? "VcsProcessTimeoutError" : "VcsProcessSpawnError", - ); - }), - ); - - it.effect.each([ - { - name: "recovers after two lock failures", - failures: 2, - attempts: 3, - transient: true, - capture: true, - streaming: false, - }, - { - name: "bounds persistent lock failures", - failures: Infinity, - attempts: 3, - transient: true, - capture: true, - streaming: false, - }, - { - name: "does not retry unknown exits", - failures: Infinity, - attempts: 1, - transient: false, - capture: true, - streaming: false, - }, - { - name: "leaves other operations unchanged", - failures: Infinity, - attempts: 1, - transient: true, - capture: false, - streaming: false, - }, - { - name: "does not replay stdout callbacks", - failures: Infinity, - attempts: 1, - transient: true, - capture: true, - streaming: true, - }, - ])( - "checkpoint command retry $name", - ({ failures, attempts: expectedAttempts, transient, capture, streaming }) => - Effect.gen(function* () { - let attempts = 0; - const service = yield* VcsProcess.make.pipe( - Effect.provideService(ProcessRunner.ProcessRunner, { - run: () => - Effect.sync(() => { - attempts += 1; - return { - stdout: "", - stderr: - attempts > failures - ? "" - : transient - ? "fatal: Unable to create '/repo/index.lock': File exists" - : "fatal: index file corrupt", - code: ChildProcessSpawner.ExitCode(attempts > failures ? 0 : attempts), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - }), - }), - ); - const fiber = yield* service - .run({ - ...baseInput, - operation: capture ? VcsProcess.CHECKPOINT_CAPTURE_OPERATION : baseInput.operation, - ...(streaming ? { onStdoutChunk: () => {} } : {}), - }) - .pipe(Effect.exit, Effect.forkScoped); - yield* TestClock.adjust("150 millis"); - const result = yield* Fiber.join(fiber); - expect(attempts).toBe(expectedAttempts); - if (failures === Infinity) { - if (Exit.isSuccess(result)) - return yield* Effect.die("Expected a capture command failure"); - const error = Cause.findErrorOption(result.cause); - if (error._tag === "None" || error.value._tag !== "VcsProcessExitError") { - return yield* Effect.die("Expected a Git exit error"); - } - expect(error.value.exitCode).toBe(expectedAttempts); - } else { - expect(Exit.isSuccess(result)).toBe(true); - } - }), - ); - it.effect("bounds a synthetic burst of GitHub API processes", () => Effect.gen(function* () { const gate = yield* Deferred.make(); @@ -370,22 +315,6 @@ describe("VcsProcess.run", () => { }), ); - it.effect("collects stdout", () => - Effect.gen(function* () { - const result = yield* run({ - operation: "test.stdout", - command: "node", - args: ["-e", "process.stdout.write('hello')"], - cwd: process.cwd(), - }); - - expect(result.stdout).toBe("hello"); - expect(result.stderr).toBe(""); - expect(result.stdoutTruncated).toBe(false); - expect(result.stderrTruncated).toBe(false); - }).pipe(provideLive), - ); - it.effect("keeps git from opening a folder laid out as a bare repository", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -434,38 +363,6 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); - it.effect("fails with VcsProcessExitError for non-zero exits by default", () => - Effect.gen(function* () { - const secretArgument = "--token=super-secret-token"; - const secretStderr = "remote rejected super-secret-token"; - const error = yield* run({ - operation: "test.exit", - command: "node", - args: [ - "-e", - "process.stderr.write(process.argv[1]); process.exit(2)", - secretStderr, - secretArgument, - ], - cwd: process.cwd(), - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(VcsProcessExitError); - expect(error).toMatchObject({ - operation: "test.exit", - command: "node", - argumentCount: 4, - exitCode: 2, - detail: "Process exited with a non-zero status.", - failureKind: "command-failed", - stderrLength: secretStderr.length, - stderrTruncated: false, - }); - expect(error.message).not.toContain(secretArgument); - expect(error.message).not.toContain(secretStderr); - }).pipe(provideLive), - ); - it.effect("classifies authentication failures without retaining stderr", () => Effect.gen(function* () { const secretStderr = "authentication failed for token super-secret-token"; @@ -516,24 +413,6 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); - it.effect("classifies HTTP 429 responses as rate limits", () => - Effect.gen(function* () { - const providerStderr = "HTTP 429: Too Many Requests. request-id=secret-value"; - const error = yield* run({ - operation: "test.rate-limit", - command: "node", - args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", providerStderr], - cwd: process.cwd(), - }).pipe(Effect.flip); - - expect(error).toMatchObject({ - detail: "API rate limit exceeded.", - failureKind: "rate-limited", - }); - expect(error.message).not.toContain(providerStderr); - }).pipe(provideLive), - ); - it.effect("retains spawn causes without exposing process arguments in the error message", () => Effect.gen(function* () { const secretArgument = "--token=super-secret-token"; @@ -555,68 +434,6 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); - it.effect("preserves real boundary causes without manufacturing structural ones", () => - Effect.gen(function* () { - const cause = new Error("secret stdin failure"); - const error = yield* captureProcessResult( - Effect.fail( - new ProcessRunner.ProcessStdinError({ - command: baseInput.command, - argumentCount: baseInput.args.length, - cwd: baseInput.cwd, - stdinBytes: 47, - cause, - }), - ), - ); - - expect(error).toMatchObject({ - _tag: "VcsProcessStdinWriteError", - operation: baseInput.operation, - stdinBytes: 47, - cause, - }); - expect(error.message).not.toContain(cause.message); - - const missingExitCodeError = yield* captureProcessResult( - Effect.succeed({ - stdout: "", - stderr: "", - code: null, - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }), - ); - - expect(missingExitCodeError).toMatchObject({ - _tag: "VcsProcessMissingExitCodeError", - operation: baseInput.operation, - command: baseInput.command, - cwd: baseInput.cwd, - argumentCount: baseInput.args.length, - }); - expect(missingExitCodeError).not.toHaveProperty("cause"); - }), - ); - - it.effect("returns output when non-zero exits are allowed", () => - Effect.gen(function* () { - const result = yield* run({ - operation: "test.allowed-exit", - command: "node", - args: ["-e", "process.stderr.write('boom'); process.exit(2)"], - cwd: process.cwd(), - allowNonZeroExit: true, - }); - - expect(result.exitCode).toBe(2); - expect(result.stderr).toBe("boom"); - }).pipe(provideLive), - ); - it.effect("truncates output and appends the marker when requested", () => Effect.gen(function* () { const result = yield* run({ @@ -634,21 +451,6 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); - it.effect("truncates without the marker when truncation markers are disabled", () => - Effect.gen(function* () { - const result = yield* run({ - operation: "test.truncate-silent", - command: "node", - args: ["-e", "process.stdout.write('x'.repeat(2048))"], - cwd: process.cwd(), - maxOutputBytes: 128, - }); - - expect(result.stdoutTruncated).toBe(true); - expect(result.stdout).not.toContain("[truncated]"); - }).pipe(provideLive), - ); - it.effect("fails with measured byte counts when output must not be truncated", () => Effect.gen(function* () { const error = yield* run({ @@ -684,21 +486,4 @@ describe("VcsProcess.run", () => { expect(Buffer.concat(chunks).toString()).toBe("x".repeat(131072) + "\0S final\0"); }).pipe(provideLive), ); - - it.effect("fails with VcsProcessTimeoutError on timeout", () => - Effect.gen(function* () { - const errorFiber = yield* run({ - operation: "test.timeout", - command: "node", - args: ["-e", "setTimeout(() => {}, 5000)"], - cwd: process.cwd(), - timeoutMs: 50, - }).pipe(Effect.flip, Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(50)); - const error = yield* Fiber.join(errorFiber); - - expect(error).toBeInstanceOf(VcsProcessTimeoutError); - }).pipe(provideLive), - ); }); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index e4eba5f0fb2e..408238950774 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -62,6 +62,10 @@ const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const VCS_PROCESS_CONCURRENCY = 8; const GITHUB_PROCESS_CONCURRENCY = 4; +/** Shown to users when a Git command fails because another process holds the index lock. */ +export const GIT_INDEX_LOCKED_DETAIL = + "Git's index is locked. Wait for other Git operations to finish, then try again."; + export const CHECKPOINT_CAPTURE_OPERATION = "GitVcsDriver.checkpoints.captureCheckpoint"; /** @@ -168,6 +172,7 @@ const isTransientGitExit = (stderr: string) => // bare write error too; explicit unrelated errors remain failures. // Linux writeout-only only starts writeback, so it must not use this fallback. const FULL_FSYNC_ARG = "core.fsyncMethod=fsync"; +const BATCH_FSYNC_ARG = "core.fsyncMethod=batch"; const WRITEOUT_ONLY_FSYNC_ARG = "core.fsyncMethod=writeout-only"; const canRetryFullFsync = (stderr: string) => /fsync error on [^\r\n]+: operation not supported\r?$/im.test(stderr) || @@ -245,13 +250,15 @@ export const make = Effect.gen(function* () { result.code !== null && result.code !== 0 && input.command === "git" && - input.args.includes(FULL_FSYNC_ARG) && + (input.args.includes(FULL_FSYNC_ARG) || input.args.includes(BATCH_FSYNC_ARG)) && canRetryFullFsync(result.stderr) ) { result = yield* spawn( { ...input, - args: input.args.map((arg) => (arg === FULL_FSYNC_ARG ? WRITEOUT_ONLY_FSYNC_ARG : arg)), + args: input.args.map((arg) => + arg === FULL_FSYNC_ARG || arg === BATCH_FSYNC_ARG ? WRITEOUT_ONLY_FSYNC_ARG : arg, + ), }, baseError, ); @@ -263,7 +270,7 @@ export const make = Effect.gen(function* () { if (!input.allowNonZeroExit && result.code !== 0) { const failureKind = classifyNonZeroExit(input.command, result.stderr); - return yield* VcsProcessExitError.fromProcessExit( + const error = VcsProcessExitError.fromProcessExit( baseError, { exitCode: result.code, @@ -275,6 +282,18 @@ export const make = Effect.gen(function* () { failureKind === "command-failed" && isTransientGitExit(result.stderr), ); + if ( + input.command === "git" && + /^fatal: unable to create ['"][^\r\n]*[/\\]index\.lock['"]: file exists\.?$/im.test( + result.stderr, + ) + ) { + return yield* new VcsProcessExitError({ + ...error, + detail: GIT_INDEX_LOCKED_DETAIL, + }); + } + return yield* error; } return { diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index 1c91cbadea8c..65e4068db361 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -14,20 +14,6 @@ const layerTest = VcsProjectConfig.layer.pipe( ); describe("VcsProjectConfig", () => { - it.layer(layerTest)("uses an explicit requested VCS kind before config", (it) => { - it.effect("returns the requested kind", () => - Effect.gen(function* () { - const config = yield* VcsProjectConfig.VcsProjectConfig; - const kind = yield* config.resolveKind({ - cwd: "/repo", - requestedKind: "jj", - }); - - assert.equal(kind, "jj"); - }), - ); - }); - it.layer(layerTest)("discovers .t3code/vcs.json from nested workspaces", (it) => { it.effect("returns the configured kind", () => Effect.gen(function* () { @@ -95,21 +81,6 @@ describe("VcsProjectConfig", () => { }); }); - it.layer(layerTest)("falls back to auto when no config exists", (it) => { - it.effect("returns auto", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-vcs-config-test-", - }); - const config = yield* VcsProjectConfig.VcsProjectConfig; - const kind = yield* config.resolveKind({ cwd: root }); - - assert.equal(kind, "auto"); - }), - ); - }); - it.layer(layerTest)("falls back to auto when config JSON is malformed", (it) => { it.effect("returns auto and logs the failed operation and path", () => { const messages: unknown[] = []; @@ -147,61 +118,4 @@ describe("VcsProjectConfig", () => { }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); }); - - it.layer(layerTest)("falls back to auto when the config path cannot be read", (it) => { - it.effect("retains the read failure context", () => { - const messages: unknown[] = []; - const logger = Logger.make(({ message }) => { - messages.push(message); - }); - - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-vcs-config-test-", - }); - const configPath = path.join(root, ".t3code", "vcs.json"); - yield* fileSystem.makeDirectory(configPath, { recursive: true }); - - const config = yield* VcsProjectConfig.VcsProjectConfig; - const kind = yield* config.resolveKind({ cwd: root }); - - assert.equal(kind, "auto"); - const [error] = messages[0] as ReadonlyArray; - assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); - assert.equal(error.message, "Failed to read VCS project config at " + configPath + "."); - assert.deepInclude(error.cause, { _tag: "PlatformError" }); - assert.deepInclude(error, { - operation: "read", - cwd: root, - configPath, - _tag: "VcsProjectConfigError", - }); - }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); - }); - }); - - it.layer(layerTest)("falls back to auto when config kind is invalid", (it) => { - it.effect("returns auto", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-vcs-config-test-", - }); - const configDir = path.join(root, ".t3code"); - yield* fileSystem.makeDirectory(configDir, { recursive: true }); - yield* fileSystem.writeFileString( - path.join(configDir, "vcs.json"), - `{"vcs":{"kind":"svn"}}`, - ); - - const config = yield* VcsProjectConfig.VcsProjectConfig; - const kind = yield* config.resolveKind({ cwd: root }); - - assert.equal(kind, "auto"); - }), - ); - }); }); diff --git a/apps/server/src/vcs/VcsProvisioningService.test.ts b/apps/server/src/vcs/VcsProvisioningService.test.ts deleted file mode 100644 index ea03ed8a41ab..000000000000 --- a/apps/server/src/vcs/VcsProvisioningService.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import { ChildProcessSpawner } from "effect/process"; - -import * as VcsDriver from "./VcsDriver.ts"; -import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; -import * as VcsProvisioningService from "./VcsProvisioningService.ts"; - -const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); - -function makeDriver(calls: string[]): VcsDriver.VcsDriver["Service"] { - return { - capabilities: { - kind: "git", - supportsWorktrees: true, - supportsBookmarks: false, - supportsAtomicSnapshot: false, - supportsPushDefaultRemote: true, - ignoreClassifier: "native", - }, - execute: () => - Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }), - detectRepository: () => Effect.succeed(null), - isInsideWorkTree: () => Effect.succeed(false), - listWorkspaceFiles: () => - Effect.succeed({ - paths: [], - truncated: false, - freshness: { - source: "live-local", - observedAt: TEST_EPOCH, - expiresAt: Option.none(), - }, - }), - listRemotes: () => - Effect.succeed({ - remotes: [], - freshness: { - source: "live-local", - observedAt: TEST_EPOCH, - expiresAt: Option.none(), - }, - }), - filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), - initRepository: (input) => - Effect.sync(() => { - calls.push(`${input.kind ?? "default"}:${input.cwd}`); - }), - }; -} - -it.effect("routes repository initialization through an explicit VCS driver kind", () => { - const calls: string[] = []; - const driver = makeDriver(calls); - const layerTest = VcsProvisioningService.layer.pipe( - Layer.provide( - Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ - get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), - }), - ), - ); - - return Effect.gen(function* () { - const provisioning = yield* VcsProvisioningService.VcsProvisioningService; - yield* provisioning.initRepository({ cwd: "/repo", kind: "git" }); - - assert.deepStrictEqual(calls, ["git:/repo"]); - }).pipe(Effect.provide(layerTest)); -}); - -it.effect("defaults repository initialization to Git until callers choose a VCS kind", () => { - const calls: string[] = []; - const driver = makeDriver(calls); - const layerTest = VcsProvisioningService.layer.pipe( - Layer.provide( - Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ - get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), - }), - ), - ); - - return Effect.gen(function* () { - const provisioning = yield* VcsProvisioningService.VcsProvisioningService; - yield* provisioning.initRepository({ cwd: "/repo" }); - - assert.deepStrictEqual(calls, ["default:/repo"]); - }).pipe(Effect.provide(layerTest)); -}); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index c8fe9dca16ff..334c0d4747bc 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1,7 +1,6 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -10,7 +9,6 @@ import * as Fiber from "effect/Fiber"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Scope from "effect/Scope"; @@ -23,7 +21,7 @@ import type { VcsStatusResult, VcsStatusStreamEvent, } from "@t3tools/contracts"; -import { GitCommandError, GitManagerError } from "@t3tools/contracts"; +import { GitManagerError } from "@t3tools/contracts"; import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; @@ -79,11 +77,18 @@ function layerTestFor(state: { remoteInvalidationCalls: number; remoteStatusRefreshUpstreamValues?: Array; backgroundWorkEnabled?: boolean; + automaticFetchInterval?: Effect.Effect; beforeRemoteStatus?: Effect.Effect; }) { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(layerBackgroundPolicy(() => state.backgroundWorkEnabled !== false)), + Layer.provide( + Layer.succeed( + VcsStatusBroadcaster.VcsAutomaticFetchInterval, + state.automaticFetchInterval ?? Effect.succeed(Duration.seconds(30)), + ), + ), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -212,159 +217,6 @@ describe("VcsStatusBroadcaster", () => { }, ); - it.effect.each([ - { refresh: "explicit", failure: "pull" }, - { refresh: "explicit", failure: "post-pull remote read" }, - { refresh: "explicit", failure: "recovery scan" }, - { refresh: "background", failure: "pull" }, - { refresh: "background", failure: "post-pull remote read" }, - { refresh: "background", failure: "recovery scan" }, - ] as const)( - "$refresh refresh reconciles local status after failure at $failure", - ({ refresh, failure }) => { - let local = { ...baseLocalStatus, isDefaultRef: true, refName: "main" }; - const afterPull = { ...local, hasWorkingTreeChanges: true }; - let pullCalls = 0; - let seeded = false; - const subscribed = Deferred.makeUnsafe(); - const outcome = Deferred.makeUnsafe<"updated" | "failed">(); - const recoveryError = new GitManagerError({ - operation: "test", - cwd: "/repo", - detail: "recovery scan failed", - }); - const logger = Logger.make(({ message }) => { - if (Array.isArray(message) && message[0] === "VCS remote status refresh failed") { - Deferred.doneUnsafe(outcome, Effect.succeed("failed")); - } - }); - const layerTest = VcsStatusBroadcaster.layer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provide(layerBackgroundPolicy(() => true)), - Layer.provide( - Layer.succeed(VcsStatusBroadcaster.VcsAutoPullPolicy, { - isEnabled: () => Effect.succeed(true), - }), - ), - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - localStatus: () => - Effect.suspend(() => - failure === "recovery scan" && pullCalls > 0 - ? Effect.fail(recoveryError) - : Effect.succeed(local), - ), - remoteStatus: () => - Effect.suspend(() => - failure === "post-pull remote read" && pullCalls > 0 - ? Effect.fail( - new GitManagerError({ - operation: "test", - cwd: "/repo", - detail: "post-pull read failed", - }), - ) - : Effect.succeed({ - ...baseRemoteStatus, - behindCount: 2, - pr: seeded ? remoteStatusWithPr.pr : null, - }), - ), - invalidateLocalStatus: () => Effect.void, - invalidateRemoteStatus: () => Effect.void, - invalidateStatus: () => Effect.void, - pullCurrentBranch: () => - Effect.suspend(() => { - pullCalls += 1; - local = afterPull; - return failure !== "post-pull remote read" - ? Effect.fail( - new GitCommandError({ - operation: "test", - command: "pull", - cwd: "/repo", - detail: "pull failed", - }), - ) - : Effect.succeed({ - status: "pulled" as const, - refName: "main", - upstreamRef: "origin/main", - }); - }), - }), - ), - ); - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - // Seed the same divergence so background recovery cannot rely on a moved remote ref. - yield* broadcaster.getStatus({ cwd: "/repo" }); - seeded = true; - if (refresh === "explicit") { - const result = yield* Effect.result(broadcaster.refreshStatus("/repo")); - if (failure === "recovery scan") { - assert.isTrue(result._tag === "Failure"); - if (result._tag === "Failure") assert.strictEqual(result.failure, recoveryError); - return; - } - assert.isTrue(result._tag === "Success"); - if (result._tag === "Success") assert.isTrue(result.success.hasWorkingTreeChanges); - } else { - yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { - if (event._tag === "snapshot" && !event.local.hasWorkingTreeChanges) { - return Deferred.succeed(subscribed, undefined).pipe(Effect.asVoid); - } - return Deferred.succeed(outcome, "updated").pipe(Effect.asVoid); - }).pipe(Effect.forkScoped); - yield* Deferred.await(subscribed); - yield* TestClock.adjust(Duration.seconds(30)); - assert.equal( - yield* Deferred.await(outcome), - failure === "recovery scan" ? "failed" : "updated", - ); - } - assert.equal(pullCalls, 1); - if (failure !== "recovery scan") { - assert.isTrue((yield* broadcaster.getStatus({ cwd: "/repo" })).hasWorkingTreeChanges); - } - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - layerTest, - TestClock.layer(), - Logger.layer([logger], { mergeWithExisting: true }), - ), - ), - ); - }, - ); - - it.effect("reuses the cached VCS status across repeated reads", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - - const first = yield* broadcaster.getStatus({ cwd: "/repo" }); - const second = yield* broadcaster.getStatus({ cwd: "/repo" }); - - assert.deepStrictEqual(first, baseStatus); - assert.deepStrictEqual(second, baseStatus); - assert.equal(state.localStatusCalls, 1); - assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.localInvalidationCalls, 0); - assert.equal(state.remoteInvalidationCalls, 0); - }).pipe(Effect.provide(layerTestFor(state))); - }); - it.effect("refreshes a loaded cwd without reusing a previous branch's PR", () => { const state = { currentLocalStatus: baseLocalStatus, @@ -484,66 +336,6 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(layer), Effect.scoped); }); - it.effect("turn-end refresh skips a loaded cwd when background policy pauses it", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - backgroundWorkEnabled: false, - }; - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - yield* broadcaster.getStatus({ cwd: "/repo" }); - yield* broadcaster.refreshPullRequestStatus("/repo"); - assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.remoteInvalidationCalls, 0); - }).pipe(Effect.provide(layerTestFor(state))); - }); - - it.effect("refreshes the cached snapshot after explicit invalidation", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); - - state.currentLocalStatus = { - ...baseLocalStatus, - refName: "feature/updated-status", - }; - state.currentRemoteStatus = { - ...baseRemoteStatus, - aheadCount: 2, - }; - const refreshed = yield* broadcaster.refreshStatus("/repo"); - const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); - - assert.deepStrictEqual(initial, baseStatus); - assert.deepStrictEqual(refreshed, { - ...state.currentLocalStatus, - ...state.currentRemoteStatus, - }); - assert.deepStrictEqual(cached, { - ...state.currentLocalStatus, - ...state.currentRemoteStatus, - }); - assert.equal(state.localStatusCalls, 2); - assert.equal(state.remoteStatusCalls, 2); - assert.equal(state.localInvalidationCalls, 1); - assert.equal(state.remoteInvalidationCalls, 1); - }).pipe(Effect.provide(layerTestFor(state))); - }); - it.effect("keeps the cached snapshot unchanged when a refresh branch fails", () => { const state = { currentLocalStatus: baseLocalStatus, @@ -616,42 +408,6 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(layerTest)); }); - it.effect("refreshes only the cached local snapshot when requested", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); - - state.currentLocalStatus = { - ...baseLocalStatus, - refName: "feature/local-only-refresh", - hasWorkingTreeChanges: true, - }; - - const refreshedLocal = yield* broadcaster.refreshLocalStatus("/repo"); - const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); - - assert.deepStrictEqual(initial, baseStatus); - assert.deepStrictEqual(refreshedLocal, state.currentLocalStatus); - assert.deepStrictEqual(cached, { - ...state.currentLocalStatus, - ...baseRemoteStatus, - }); - assert.equal(state.localStatusCalls, 2); - assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.localInvalidationCalls, 1); - assert.equal(state.remoteInvalidationCalls, 0); - }).pipe(Effect.provide(layerTestFor(state))); - }); - it.effect.skipIf(!symlinksSupported)( "normalizes symlinked CWDs before cache lookup and workflow calls", () => { @@ -717,329 +473,6 @@ describe("VcsStatusBroadcaster", () => { }, ); - it.effect("passive streams retain cached remote status", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - const snapshotDeferred = yield* Deferred.make(); - const remoteUpdatedDeferred = yield* Deferred.make(); - yield* Stream.runForEach( - broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), - (event) => { - if (event._tag === "snapshot") { - return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); - } - if (event._tag === "remoteUpdated") { - return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); - } - return Effect.void; - }, - ).pipe(Effect.forkScoped); - - const snapshot = yield* Deferred.await(snapshotDeferred); - yield* TestClock.adjust("1 minute"); - assert.equal(state.remoteStatusCalls, 0); - yield* broadcaster.getStatus({ cwd: "/repo" }); - state.currentRemoteStatus = remoteStatusWithPr; - yield* broadcaster.refreshPullRequestStatus("/repo"); - const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); - - assert.deepStrictEqual(snapshot, { - _tag: "snapshot", - local: baseLocalStatus, - remote: null, - } satisfies VcsStatusStreamEvent); - assert.deepStrictEqual(remoteUpdated, { - _tag: "remoteUpdated", - remote: remoteStatusWithPr, - } satisfies VcsStatusStreamEvent); - const cachedSnapshot = yield* Stream.runHead( - broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), - ); - assert.deepStrictEqual(Option.getOrThrow(cachedSnapshot), { - _tag: "snapshot", - local: baseLocalStatus, - remote: remoteStatusWithPr, - }); - }).pipe(Effect.provide(layerTestFor(state))); - }); - - it.effect("loads remote status once when periodic refreshes are disabled", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: remoteStatusWithPr, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - remoteStatusRefreshUpstreamValues: [] as Array, - }; - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - const scope = yield* Scope.make(); - const snapshotDeferred = yield* Deferred.make(); - const remoteUpdatedDeferred = yield* Deferred.make(); - yield* Stream.runForEach( - broadcaster.streamStatus( - { cwd: "/repo" }, - { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, - ), - (event) => { - if (event._tag === "snapshot") { - return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); - } - if (event._tag === "remoteUpdated") { - return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); - } - return Effect.void; - }, - ).pipe(Effect.forkIn(scope)); - - const snapshot = yield* Deferred.await(snapshotDeferred); - const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); - - assert.deepStrictEqual(snapshot, { - _tag: "snapshot", - local: baseLocalStatus, - remote: null, - } satisfies VcsStatusStreamEvent); - assert.deepStrictEqual(remoteUpdated, { - _tag: "remoteUpdated", - remote: remoteStatusWithPr, - } satisfies VcsStatusStreamEvent); - assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.remoteInvalidationCalls, 0); - assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false]); - - yield* TestClock.adjust(Duration.minutes(2)); - assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.remoteInvalidationCalls, 0); - - yield* Scope.close(scope, Exit.void); - }).pipe(Effect.provide(Layer.merge(layerTestFor(state), TestClock.layer()))); - }); - - it.effect("retries the initial remote load when periodic refreshes are disabled", () => { - const state = { - currentLocalStatus: baseLocalStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - remoteStatusRefreshUpstreamValues: [] as Array, - }; - const privateCwd = "/private/user/workspace/repo"; - const nestedCause = new Error("private nested VCS failure"); - const messages: Array> = []; - const logger = Logger.make(({ message }) => { - messages.push(message as ReadonlyArray); - }); - let firstRemoteAttemptDeferred: Deferred.Deferred | null = null; - const layerTest = VcsStatusBroadcaster.layer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provide(layerBackgroundPolicy(() => true)), - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - localStatus: () => - Effect.sync(() => { - state.localStatusCalls += 1; - return state.currentLocalStatus; - }), - remoteStatus: (_input, options) => - Effect.suspend(() => { - state.remoteStatusCalls += 1; - state.remoteStatusRefreshUpstreamValues.push(options?.refreshUpstream); - if (state.remoteStatusCalls === 1) { - return Effect.fail( - new GitManagerError({ - operation: "VcsStatusBroadcaster.test", - cwd: privateCwd, - detail: "private initial remote status failure", - cause: nestedCause, - }), - ).pipe( - Effect.ensuring( - firstRemoteAttemptDeferred - ? Deferred.succeed(firstRemoteAttemptDeferred, undefined).pipe(Effect.ignore) - : Effect.void, - ), - ); - } - return Effect.succeed(remoteStatusWithPr); - }), - invalidateLocalStatus: () => - Effect.sync(() => { - state.localInvalidationCalls += 1; - }), - invalidateRemoteStatus: () => - Effect.sync(() => { - state.remoteInvalidationCalls += 1; - }), - }), - ), - ); - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - const scope = yield* Scope.make(); - firstRemoteAttemptDeferred = yield* Deferred.make(); - const remoteUpdatedDeferred = yield* Deferred.make(); - yield* Stream.runForEach( - broadcaster.streamStatus( - { cwd: privateCwd }, - { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, - ), - (event) => - event._tag === "remoteUpdated" - ? Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore) - : Effect.void, - ).pipe(Effect.forkIn(scope)); - - yield* Deferred.await(firstRemoteAttemptDeferred); - yield* Effect.yieldNow; - assert.equal(state.remoteStatusCalls, 1); - assert.deepStrictEqual( - messages.find((message) => message[0] === "VCS remote status refresh failed"), - [ - "VCS remote status refresh failed", - { - cwdLength: privateCwd.length, - reasonCount: 1, - failureCount: 1, - failureTags: ["GitManagerError"], - failureOperations: ["VcsStatusBroadcaster.test"], - defectCount: 0, - defectTags: [], - interruptionCount: 0, - consecutiveFailures: 1, - nextDelayMs: 30_000, - }, - ], - ); - - yield* TestClock.adjust(Duration.seconds(30)); - const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); - - assert.deepStrictEqual(remoteUpdated, { - _tag: "remoteUpdated", - remote: remoteStatusWithPr, - } satisfies VcsStatusStreamEvent); - assert.equal(state.remoteStatusCalls, 2); - assert.equal(state.remoteInvalidationCalls, 0); - assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false, false]); - - yield* Scope.close(scope, Exit.void); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerTest, - TestClock.layer(), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); - }); - - it.effect("delays automatic refresh when a cached remote snapshot is available", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - yield* broadcaster.getStatus({ cwd: "/repo" }); - const scope = yield* Scope.make(); - const snapshotDeferred = yield* Deferred.make(); - yield* Stream.runForEach( - broadcaster.streamStatus( - { cwd: "/repo" }, - { automaticRemoteRefreshInterval: Effect.succeed(Duration.minutes(1)) }, - ), - (event) => - event._tag === "snapshot" - ? Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore) - : Effect.void, - ).pipe(Effect.forkIn(scope)); - - yield* Deferred.await(snapshotDeferred); - assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.remoteInvalidationCalls, 0); - - yield* TestClock.adjust(Duration.seconds(59)); - assert.equal(state.remoteStatusCalls, 1); - - yield* TestClock.adjust(Duration.seconds(1)); - yield* Effect.yieldNow; - assert.equal(state.remoteStatusCalls, 2); - assert.equal(state.remoteInvalidationCalls, 1); - - yield* Scope.close(scope, Exit.void); - }).pipe(Effect.provide(Layer.merge(layerTestFor(state), TestClock.layer()))); - }); - - it.effect.each([ - ["a push", { ...baseRemoteStatus, aheadCount: 1, aheadOfDefaultCount: 0 }], - ["a merged pull request", { ...baseRemoteStatus, aheadOfDefaultCount: 2 }], - ] as const)("re-reads local status when a fetch reflects %s", ([, initialRemote]) => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: initialRemote as VcsStatusRemoteResult | null, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - yield* broadcaster.getStatus({ cwd: "/repo" }); - const scope = yield* Scope.make(); - const snapshotDeferred = yield* Deferred.make(); - const localUpdatedDeferred = yield* Deferred.make(); - yield* Stream.runForEach( - broadcaster.streamStatus( - { cwd: "/repo" }, - { automaticRemoteRefreshInterval: Effect.succeed(Duration.minutes(1)) }, - ), - (event) => - event._tag === "snapshot" - ? Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore) - : event._tag === "localUpdated" - ? Deferred.succeed(localUpdatedDeferred, event).pipe(Effect.ignore) - : Effect.void, - ).pipe(Effect.forkIn(scope)); - yield* Deferred.await(snapshotDeferred); - - const pushedLocal: VcsStatusLocalResult = { - ...baseLocalStatus, - branchChanges: { baseRef: "origin/main", insertions: 0, deletions: 0 }, - }; - state.currentLocalStatus = pushedLocal; - state.currentRemoteStatus = { ...baseRemoteStatus, aheadOfDefaultCount: 0 }; - yield* TestClock.adjust(Duration.minutes(1)); - - assert.deepStrictEqual(yield* Deferred.await(localUpdatedDeferred), { - _tag: "localUpdated", - local: pushedLocal, - } satisfies VcsStatusStreamEvent); - yield* Scope.close(scope, Exit.void); - }).pipe(Effect.provide(Layer.merge(layerTestFor(state), TestClock.layer()))); - }); - it.effect("an explicit refresh reads local totals after the fetch", () => { const state: Parameters[0] = { currentLocalStatus: baseLocalStatus, @@ -1072,101 +505,6 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(layerTestFor(state))); }); - it("backs off remote refresh failures exponentially and honors larger configured intervals", () => { - assert.equal( - Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.seconds(1))), - 30_000, - ); - assert.equal( - Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(2, Duration.seconds(1))), - 60_000, - ); - assert.equal( - Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(3, Duration.seconds(1))), - 120_000, - ); - assert.equal( - Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.minutes(5))), - 300_000, - ); - assert.equal( - Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(20, Duration.seconds(1))), - 900_000, - ); - }); - - it("summarizes refresh causes without exposing nested failure details", () => { - const nestedCause = new Error("private nested failure detail"); - const failure = new GitManagerError({ - operation: "VcsStatusBroadcaster.remoteStatus", - cwd: "/private/user/workspace/repo", - detail: "private Git failure detail", - cause: nestedCause, - }); - const cause = Cause.combine(Cause.fail(failure), Cause.die(new TypeError("private defect"))); - - assert.deepStrictEqual(VcsStatusBroadcaster.remoteRefreshFailureDiagnostics(cause), { - reasonCount: 2, - failureCount: 1, - failureTags: ["GitManagerError"], - failureOperations: ["VcsStatusBroadcaster.remoteStatus"], - defectCount: 1, - defectTags: ["TypeError"], - interruptionCount: 0, - }); - }); - - it.effect("does not start automatic remote refreshes without foreground client demand", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - const layerTest = VcsStatusBroadcaster.layer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provide(layerBackgroundPolicy(() => false)), - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - localStatus: () => - Effect.sync(() => { - state.localStatusCalls += 1; - return state.currentLocalStatus; - }), - remoteStatus: () => - Effect.sync(() => { - state.remoteStatusCalls += 1; - return state.currentRemoteStatus; - }), - invalidateLocalStatus: () => - Effect.sync(() => { - state.localInvalidationCalls += 1; - }), - invalidateRemoteStatus: () => - Effect.sync(() => { - state.remoteInvalidationCalls += 1; - }), - } satisfies Partial), - ), - ); - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - const snapshot = yield* Stream.runHead( - broadcaster.streamStatus( - { cwd: "/repo" }, - { automaticRemoteRefreshInterval: Effect.succeed(Duration.seconds(1)) }, - ), - ); - - assert.isTrue(Option.isSome(snapshot)); - assert.equal(state.remoteStatusCalls, 0); - assert.equal(state.remoteInvalidationCalls, 0); - }).pipe(Effect.provide(layerTest)); - }); - it.effect("releases remote demand while passive observers remain", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 2bfdbfbe7cd2..41859b9dd907 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -21,6 +21,8 @@ import type { VcsStatusStreamEvent, VcsStatusSubscriptionInput, } from "@t3tools/contracts"; +import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL } from "@t3tools/contracts"; +import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { mergeGitStatusParts } from "@t3tools/shared/git"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; @@ -30,7 +32,6 @@ import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ServerSettings from "../serverSettings.ts"; -const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_MAX_DELAY = Duration.minutes(15); const MAX_FAILURE_DIAGNOSTIC_VALUES = 8; @@ -85,7 +86,7 @@ function addUniqueDiagnosticValue(values: Array, value: string | undefin } } -export function remoteRefreshFailureDiagnostics(cause: Cause.Cause) { +function remoteRefreshFailureDiagnostics(cause: Cause.Cause) { const failureTags: Array = []; const failureOperations: Array = []; const defectTags: Array = []; @@ -140,9 +141,23 @@ interface ActiveRemotePoller { readonly demandCwds: Ref.Ref>; } -interface StreamStatusOptions { - readonly automaticRemoteRefreshInterval?: Effect.Effect; -} +export class VcsAutomaticFetchInterval extends Context.Reference>( + "t3/vcs/VcsAutomaticFetchInterval", + { defaultValue: () => Effect.succeed(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL) }, +) {} + +export const layerAutomaticFetchInterval = Layer.effect( + VcsAutomaticFetchInterval, + Effect.gen(function* () { + const serverSettings = yield* ServerSettings.ServerSettingsService; + return serverSettings.getSettings.pipe( + Effect.map( + (settings) => resolveServerBackgroundActivitySettings(settings).automaticGitFetchInterval, + ), + Effect.orElseSucceed(() => DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL), + ); + }), +); export class VcsAutoPullPolicy extends Context.Reference<{ readonly isEnabled: (cwd: string) => Effect.Effect; @@ -169,7 +184,7 @@ export const layerAutoPullPolicy = Layer.effect( }), ); -export function remoteRefreshFailureDelay( +function remoteRefreshFailureDelay( consecutiveFailures: number, configuredInterval: Duration.Duration, ) { @@ -198,7 +213,6 @@ export class VcsStatusBroadcaster extends Context.Service< ) => Effect.Effect; readonly streamStatus: ( input: VcsStatusSubscriptionInput, - options?: StreamStatusOptions, ) => Stream.Stream; } >()("t3/vcs/VcsStatusBroadcaster") {} @@ -215,6 +229,7 @@ const normalizeCwd = (cwd: string) => /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { + const automaticFetchInterval = yield* VcsAutomaticFetchInterval; const autoPullPolicy = yield* VcsAutoPullPolicy; const workflow = yield* GitWorkflowService.GitWorkflowService; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; @@ -374,10 +389,13 @@ export const make = Effect.gen(function* () { cwd, Effect.gen(function* () { const latest = yield* getCachedStatus(cwd); + const refreshUpstream = !Duration.isZero(yield* automaticFetchInterval); const [local, remote] = yield* Effect.all( [ latest?.local ? Effect.succeed(latest.local.value) : workflow.localStatus({ cwd }), - latest?.remote ? Effect.succeed(latest.remote.value) : workflow.remoteStatus({ cwd }), + latest?.remote + ? Effect.succeed(latest.remote.value) + : workflow.remoteStatus({ cwd }, { refreshUpstream }), ], { concurrency: "unbounded" }, ); @@ -468,7 +486,10 @@ export const make = Effect.gen(function* () { } const previousRemote = (yield* getCachedStatus(cwd))?.remote?.value; const remote = yield* workflow.remoteStatus({ cwd }, options); - const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); + const pulled = + options?.refreshUpstream === false + ? null + : yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); if (pulled !== null) return pulled.remote; if ( remote && @@ -493,11 +514,13 @@ export const make = Effect.gen(function* () { return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { + const refreshUpstream = !Duration.isZero(yield* automaticFetchInterval); yield* workflow.invalidateStatus(cwd); // Local after remote: the fetch can move the base that the Changes totals compare with. - const remote = yield* workflow.remoteStatus({ cwd }); + const remote = yield* workflow.remoteStatus({ cwd }, { refreshUpstream }); const local = yield* workflow.localStatus({ cwd }); - const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); + // Without a fetch, the cached behind count cannot justify an automatic pull. + const pulled = refreshUpstream ? yield* maybeAutoPull(cwd, remote, [rawCwd]) : null; if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); return yield* updateCachedStatus(cwd, local, remote, { publish: true }); }), @@ -532,94 +555,91 @@ export const make = Effect.gen(function* () { ); }); - const makeRemoteRefreshLoop = ( + // A subscription owns this loop; keep each poll's spans out of a never-ending parent span. + const makeRemoteRefreshLoop = Effect.fnUntraced(function* ( cwd: string, demandCwdsRef: Ref.Ref>, - automaticRemoteRefreshInterval: Effect.Effect, refreshImmediately: boolean, - ) => { - return Effect.gen(function* () { - const consecutiveFailuresRef = yield* Ref.make(0); - const needsInitialRefreshRef = yield* Ref.make(refreshImmediately); - const refreshRemoteStatusIfEnabled = Effect.gen(function* () { - const configuredInterval = yield* automaticRemoteRefreshInterval; - const activeInterval = Duration.isZero(configuredInterval) - ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL - : configuredInterval; - const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef); - if (Duration.isZero(configuredInterval) && !needsInitialRefresh) { - return activeInterval; - } + ) { + const consecutiveFailuresRef = yield* Ref.make(0); + const needsInitialRefreshRef = yield* Ref.make(refreshImmediately); + const refreshRemoteStatusIfEnabled = Effect.gen(function* () { + const configuredInterval = yield* automaticFetchInterval; + const activeInterval = Duration.isZero(configuredInterval) + ? DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL + : configuredInterval; + const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef); + if (Duration.isZero(configuredInterval) && !needsInitialRefresh) { + return activeInterval; + } - const demandCwds = yield* Ref.get(demandCwdsRef); - const shouldRun = - needsInitialRefresh || - (yield* Effect.forEach( - [...demandCwds.keys()], - (demandCwd) => - backgroundPolicy.shouldRunScopeWork({ - type: "vcs-status", - cwd: demandCwd, - }), - { concurrency: "unbounded" }, - )).some(Boolean); - if (!shouldRun) { - return activeInterval; - } + const demandCwds = yield* Ref.get(demandCwdsRef); + const shouldRun = + needsInitialRefresh || + (yield* Effect.forEach( + [...demandCwds.keys()], + (demandCwd) => + backgroundPolicy.shouldRunScopeWork({ + type: "vcs-status", + cwd: demandCwd, + }), + { concurrency: "unbounded" }, + )).some(Boolean); + if (!shouldRun) { + return activeInterval; + } - const exit = yield* refreshRemoteStatus(cwd, { - refreshUpstream: !Duration.isZero(configuredInterval), - policyCwds: [...demandCwds.keys()], - }).pipe(Effect.exit); - if (Exit.isSuccess(exit)) { - yield* Ref.set(needsInitialRefreshRef, false); - yield* Ref.set(consecutiveFailuresRef, 0); - return activeInterval; - } + const exit = yield* refreshRemoteStatus(cwd, { + refreshUpstream: !Duration.isZero(configuredInterval), + policyCwds: [...demandCwds.keys()], + }).pipe(Effect.exit); + if (Exit.isSuccess(exit)) { + yield* Ref.set(needsInitialRefreshRef, false); + yield* Ref.set(consecutiveFailuresRef, 0); + return activeInterval; + } - const interruptionReasons = exit.cause.reasons.filter(Cause.isInterruptReason); - if (interruptionReasons.length > 0) { - return yield* Effect.failCause(Cause.fromReasons(interruptionReasons)); - } + const interruptionReasons = exit.cause.reasons.filter(Cause.isInterruptReason); + if (interruptionReasons.length > 0) { + return yield* Effect.failCause(Cause.fromReasons(interruptionReasons)); + } - const consecutiveFailures = yield* Ref.updateAndGet( - consecutiveFailuresRef, - (count) => count + 1, - ); - const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval); - yield* Effect.logWarning("VCS remote status refresh failed", { - cwdLength: cwd.length, - ...remoteRefreshFailureDiagnostics(exit.cause), - consecutiveFailures, - nextDelayMs: Duration.toMillis(nextDelay), - }); - return nextDelay; + const consecutiveFailures = yield* Ref.updateAndGet( + consecutiveFailuresRef, + (count) => count + 1, + ); + const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval); + yield* Effect.logWarning("VCS remote status refresh failed", { + cwdLength: cwd.length, + ...remoteRefreshFailureDiagnostics(exit.cause), + consecutiveFailures, + nextDelayMs: Duration.toMillis(nextDelay), }); + return nextDelay; + }); - if (!refreshImmediately) { - const configuredInterval = yield* automaticRemoteRefreshInterval; - yield* Effect.sleep( - Duration.isZero(configuredInterval) - ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL - : configuredInterval, - ); - } + if (!refreshImmediately) { + const configuredInterval = yield* automaticFetchInterval; + yield* Effect.sleep( + Duration.isZero(configuredInterval) + ? DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL + : configuredInterval, + ); + } - return yield* refreshRemoteStatusIfEnabled.pipe( - Effect.repeat( - Schedule.identity().pipe( - Schedule.addDelay(({ output: delay }) => Effect.succeed(delay)), - ), + return yield* refreshRemoteStatusIfEnabled.pipe( + Effect.repeat( + Schedule.identity().pipe( + Schedule.addDelay(({ output: delay }) => Effect.succeed(delay)), ), - Effect.asVoid, - ); - }); - }; + ), + Effect.asVoid, + ); + }); const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( cwd: string, demandCwd: string, - automaticRemoteRefreshInterval: Effect.Effect, refreshImmediately: boolean, ) { yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { @@ -643,12 +663,7 @@ export const make = Effect.gen(function* () { return Ref.make>(new Map([[demandCwd, 1]])).pipe( Effect.flatMap((demandCwds) => - makeRemoteRefreshLoop( - cwd, - demandCwds, - automaticRemoteRefreshInterval, - refreshImmediately, - ).pipe( + makeRemoteRefreshLoop(cwd, demandCwds, refreshImmediately).pipe( Effect.forkIn(broadcasterScope), Effect.map((fiber) => { const nextPollers = new Map(activePollers); @@ -707,7 +722,7 @@ export const make = Effect.gen(function* () { } }); - const streamStatus: VcsStatusBroadcaster["Service"]["streamStatus"] = (input, options) => + const streamStatus: VcsStatusBroadcaster["Service"]["streamStatus"] = (input) => Stream.unwrap( Effect.gen(function* () { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); @@ -719,8 +734,6 @@ export const make = Effect.gen(function* () { yield* retainRemotePoller( cwd, input.cwd, - options?.automaticRemoteRefreshInterval ?? - Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), cachedStatus?.remote === null || cachedStatus?.remote === undefined, ); } diff --git a/apps/server/src/vcs/removeEmptyDirectory.ts b/apps/server/src/vcs/removeEmptyDirectory.ts new file mode 100644 index 000000000000..2ce80dfe588a --- /dev/null +++ b/apps/server/src/vcs/removeEmptyDirectory.ts @@ -0,0 +1,18 @@ +// @effect-diagnostics nodeBuiltinImport:off - Effect's FileSystem has no rmdir. +import * as NodeFSP from "node:fs/promises"; + +import * as Effect from "effect/Effect"; + +/** + * Removes `path` only while it is an empty directory. Succeeds with false when + * anything else is there, including files written after the caller looked. + * Other failures, such as a permission error, stay errors. + */ +export const removeEmptyDirectory = (path: string) => + Effect.tryPromise(() => NodeFSP.rmdir(path)).pipe( + Effect.as(true), + Effect.catchIf( + (error) => (error.cause as NodeJS.ErrnoException | undefined)?.code === "ENOTEMPTY", + () => Effect.succeed(false), + ), + ); diff --git a/apps/server/src/vcs/worktreeRemoval.ts b/apps/server/src/vcs/worktreeRemoval.ts new file mode 100644 index 000000000000..c8feb71c3d84 --- /dev/null +++ b/apps/server/src/vcs/worktreeRemoval.ts @@ -0,0 +1,164 @@ +import { GitCommandError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import type * as GitVcsDriver from "./GitVcsDriver.ts"; + +const INDEX_LIST_MAX_BYTES = 64 * 1024 * 1024; + +/** + * Removes the linked worktree at `worktreePath` by running `git worktree + * remove` through `run`. + * + * Git for Windows before 2.54 follows NTFS junctions while it deletes a + * worktree, and deletes what they point to, such as a shared node_modules. + * Later versions skip junctions but leave them, and the folders holding them, + * behind. On Windows, Git therefore never deletes the files: this makes the + * checks `git worktree remove` makes before deleting (builtin/worktree.c), + * deletes the folder with Node's `rm`, which unlinks junctions without + * following them, and then has Git drop the now-missing worktree's metadata. + * Git elsewhere only unlinks links, so other hosts run Git as before. + */ +export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorktree")(function* < + A, + E, + R, +>(input: { + readonly operation: string; + readonly cwd: string; + readonly worktreePath: string; + readonly force: boolean; + readonly execute: GitVcsDriver.GitVcsDriver["Service"]["execute"]; + readonly run: (args: ReadonlyArray) => Effect.Effect; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const worktreePath = input.worktreePath; + const gitFile = path.join(worktreePath, ".git"); + // Git refuses to remove a worktree with untracked files unless forced, but + // its check honors `status.showUntrackedFiles=no` and would delete them. + const statusConfig = ["-c", "status.showUntrackedFiles=normal"]; + const linked = + (yield* HostProcess.isWindows) && + (yield* fileSystem.stat(gitFile).pipe( + Effect.map((info) => info.type === "File"), + Effect.orElseSucceed(() => false), + )); + if (!linked) { + return yield* input.run([ + ...statusConfig, + "worktree", + "remove", + ...(input.force ? ["--force"] : []), + "--", + worktreePath, + ]); + } + + // Git's own refusals, worded the way it prints them. + const refuse = (message: string, cause?: unknown) => + new GitCommandError({ + operation: input.operation, + command: "git worktree remove", + cwd: input.cwd, + detail: `fatal: ${message}`, + ...(cause === undefined ? {} : { cause }), + }); + const gitOutput = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => + input + .execute({ + operation: input.operation, + cwd, + args, + maxOutputBytes: INDEX_LIST_MAX_BYTES, + ...(env === undefined ? {} : { env }), + }) + .pipe(Effect.map((result) => result.stdout)); + const samePath = (left: string, right: string) => + Effect.all([fileSystem.realPath(left), fileSystem.realPath(right)]).pipe( + Effect.map(([a, b]) => a.toLowerCase() === b.toLowerCase()), + Effect.orElseSucceed(() => false), + ); + + // A registered worktree of this repository: its `.git` file names an admin + // directory under the common `worktrees/`, whose `gitdir` points back. + const notAWorktree = refuse(`'${worktreePath}' is not a working tree`); + const adminDir = path.resolve( + worktreePath, + (yield* gitOutput(worktreePath, ["rev-parse", "--absolute-git-dir"]).pipe( + Effect.mapError(() => notAWorktree), + )).trim(), + ); + const commonDir = path.resolve( + input.cwd, + (yield* gitOutput(input.cwd, ["rev-parse", "--git-common-dir"])).trim(), + ); + const pointsBack = yield* fileSystem.readFileString(path.join(adminDir, "gitdir")).pipe( + Effect.flatMap((gitdir) => samePath(path.resolve(adminDir, gitdir.trim()), gitFile)), + Effect.orElseSucceed(() => false), + ); + if (!pointsBack || !(yield* samePath(path.dirname(adminDir), path.join(commonDir, "worktrees")))) + return yield* notAWorktree; + // Git counts the file's existence as a lock. T3 passes at most one + // `--force`, which never removes a locked worktree. Anything but a + // confirmed absence refuses. + const lockFile = path.join(adminDir, "locked"); + const locked = yield* fileSystem.stat(lockFile).pipe( + Effect.as(true), + Effect.catch((error) => + error.reason._tag === "NotFound" + ? Effect.succeed(false) + : Effect.fail(refuse(`cannot inspect the lock of '${worktreePath}'`, error)), + ), + ); + if (locked) { + const reason = (yield* fileSystem.readFileString(lockFile).pipe(Effect.option)).pipe( + Option.map((text) => text.trim()), + Option.getOrElse(() => ""), + ); + return yield* refuse( + reason === "" + ? "cannot remove a locked working tree;\nuse 'remove -f -f' to override or unlock first" + : `cannot remove a locked working tree, lock reason: ${reason}\nuse 'remove -f -f' to override or unlock first`, + ); + } + if (!input.force) { + // Bound like Git's own check, so `core.worktree` can't point it elsewhere. + const boundEnv = { GIT_DIR: gitFile, GIT_WORK_TREE: worktreePath }; + const submodules = refuse("working trees containing submodules cannot be moved or removed"); + // An unreadable path counts as present, so a doubt refuses. + const present = (target: string) => + fileSystem.exists(target).pipe(Effect.orElseSucceed(() => true)); + if (yield* present(path.join(adminDir, "modules"))) return yield* submodules; + const index = yield* gitOutput(worktreePath, ["ls-files", "--stage", "-z"], boundEnv); + for (const entry of index.split("\0")) { + if (!entry.startsWith("160000 ")) continue; + const submodule = entry.slice(entry.indexOf("\t") + 1); + if (yield* present(path.join(worktreePath, submodule, ".git"))) return yield* submodules; + } + const status = yield* gitOutput( + worktreePath, + [...statusConfig, "status", "--porcelain", "--ignore-submodules=none"], + boundEnv, + ); + if (status !== "") + return yield* refuse( + `'${worktreePath}' contains modified or untracked files, use --force to delete it`, + ); + } + + // A file held open can stop the delete partway. Putting `.git` back, when + // that works, leaves a worktree that a retry can remove. + const gitFileContents = yield* fileSystem + .readFileString(gitFile) + .pipe(Effect.mapError(() => notAWorktree)); + yield* fileSystem.remove(worktreePath, { recursive: true }).pipe( + Effect.tapError(() => fileSystem.writeFileString(gitFile, gitFileContents).pipe(Effect.ignore)), + Effect.mapError((cause) => refuse(`failed to delete '${worktreePath}'`, cause)), + ); + // The folder is gone, so Git only drops the worktree's metadata. + return yield* input.run(["worktree", "remove", "--force", "--force", "--", worktreePath]); +}); diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index d700ca9547e0..150ac2c69131 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1,7 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { FileFinder } from "@ff-labs/fff-node"; import { it, afterEach, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -253,39 +251,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect( - "browses a workspace with more than 25,000 entries without truncation", - () => - Effect.gen(function* () { - const cwd = yield* makeTempDir(); - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - for (let directory = 0; directory < 26; directory++) { - const directoryPath = path.join(cwd, `folder-${directory}`); - yield* fileSystem.makeDirectory(directoryPath); - yield* Effect.forEach( - Array.from({ length: 1000 }, (_, i) => i), - (i) => fileSystem.writeFileString(path.join(directoryPath, `file-${i}.txt`), ""), - { concurrency: 32, discard: true }, - ); - } - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const root = yield* workspaceEntries.list({ cwd, directoryPath: "" }); - expect(root.entries).toHaveLength(26); - expect(root.truncated).toBe(false); - for (const directory of root.entries) { - const result = yield* workspaceEntries.list({ cwd, directoryPath: directory.path }); - expect(result.entries).toHaveLength(1000); - expect(result.truncated).toBe(false); - expect(result.entries).toContainEqual({ - path: `${directory.path}/file-999.txt`, - kind: "file", - }); - } - }), - 60_000, - ); - it.effect("includes images and other binary files in non-git workspace indexes", () => Effect.gen(function* () { const cwd = yield* makeTempDir(); @@ -321,116 +286,32 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect(images.truncated).toBe(false); }), ); + }); - it.effect("returns the complete cached workspace index", () => + describe("listIgnoredFiles", () => { + it.effect("lists ignored files with sizes and skips wholly ignored folders", () => Effect.gen(function* () { - const cwd = yield* makeTempDir(); - yield* writeTextFile(cwd, "src/components/Composer.tsx"); - yield* writeTextFile(cwd, "README.md"); - yield* writeTextFile(cwd, "node_modules/pkg/index.js"); + const cwd = yield* makeTempDir({ git: true }); + yield* writeTextFile(cwd, ".gitignore", "node_modules/\n.env*\n"); + yield* writeTextFile(cwd, ".env", "A=1"); + yield* writeTextFile(cwd, "apps/web/index.ts", "y"); + yield* git(cwd, ["add", "apps/web/index.ts"]); + yield* writeTextFile(cwd, "apps/web/.env.local", "B=22"); + yield* writeTextFile(cwd, "node_modules/pkg/index.js", "x"); const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.list({ cwd }); - - expect(result.entries).toEqual( - expect.arrayContaining([ - { path: "src", kind: "directory" }, - { path: "src/components", kind: "directory" }, - { - path: "src/components/Composer.tsx", - kind: "file", - }, - { path: "README.md", kind: "file" }, - ]), - ); - expect(result.entries.some((entry) => entry.path.startsWith("node_modules"))).toBe(false); - expect(result.truncated).toBe(false); + expect(yield* workspaceEntries.listIgnoredFiles({ cwd })).toEqual({ + files: [ + { path: ".env", byteLength: 3 }, + { path: "apps/web/.env.local", byteLength: 4 }, + ], + truncated: false, + }); }), ); }); 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("prioritizes exact basename matches ahead of broader path matches", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-exact-ranking-" }); - yield* writeTextFile(cwd, "src/components/Composer.tsx"); - yield* writeTextFile(cwd, "docs/composer.tsx-notes.md"); - - const result = yield* searchWorkspaceEntries({ cwd, query: "Composer.tsx", limit: 5 }); - - expect(result.entries[0]?.path).toBe("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("applies the file filter before limiting search results", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-file-limit-" }); @@ -452,44 +333,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect("answers an empty file-filtered query with a bounded file listing", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-empty-query-" }); - yield* writeTextFile(cwd, "src/index.ts"); - yield* writeTextFile(cwd, "README.md"); - - const result = yield* searchWorkspaceEntries({ - cwd, - query: "", - limit: 10, - kind: "file", - }); - - const paths = result.entries.map((entry) => entry.path); - expect(paths).toHaveLength(2); - expect(paths).toContain("src/index.ts"); - expect(paths).toContain("README.md"); - expect(result.entries.every((entry) => entry.kind === "file")).toBe(true); - }), - ); - - it.effect("returns only directories for the directory filter", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-directory-filter-" }); - yield* writeTextFile(cwd, "src/index.ts"); - - const result = yield* searchWorkspaceEntries({ - cwd, - query: "src", - limit: 10, - kind: "directory", - }); - - expect(result.entries).toEqual([{ path: "src", kind: "directory" }]); - expect(result.truncated).toBe(false); - }), - ); - it.effect("excludes gitignored paths for git repositories", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true }); @@ -529,57 +372,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { 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("supports typo-resistant file search through fff", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fff-typo-" }); - yield* writeTextFile(cwd, "src/components/Composer.tsx"); - - const result = yield* searchWorkspaceEntries({ cwd, query: "compoesr", limit: 10 }); - - expect(result.entries).toEqual( - expect.arrayContaining([ - expect.objectContaining({ path: "src/components/Composer.tsx" }), - ]), - ); - }), - ); - - it.effect("rebuilds the cached index after refresh fails", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-refresh-failure-" }); - yield* writeTextFile(cwd, "src/index.ts", "export {};\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const createSpy = vi.spyOn(FileFinder, "create"); - yield* workspaceEntries.list({ cwd }); - expect(createSpy).toHaveBeenCalledTimes(1); - - vi.spyOn(FileFinder.prototype, "scanFiles").mockReturnValueOnce({ - ok: false, - error: "scan failed", - }); - yield* workspaceEntries.refresh(cwd); - - yield* workspaceEntries.list({ cwd }); - expect(createSpy).toHaveBeenCalledTimes(2); - }), - ); }); describe("searchContents", () => { @@ -634,36 +426,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect("filters whole-word matches by word boundaries without widening ranges", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-whole-word-" }); - yield* writeTextFile(cwd, "src/words.ts", "note notes denote\nfootnote note\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "note", - limit: 100, - caseSensitive: true, - wholeWord: true, - useRegex: false, - }); - - expect(result.matches).toEqual([ - expect.objectContaining({ - path: "src/words.ts", - lineNumber: 1, - matchRanges: [{ start: 0, end: 4 }], - }), - expect.objectContaining({ - path: "src/words.ts", - lineNumber: 2, - matchRanges: [{ start: 9, end: 13 }], - }), - ]); - }), - ); - it.effect("finds later whole-word matches in a file after rejected raw matches", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-late-whole-word-" }); @@ -742,33 +504,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect("matches punctuation-edged regex queries as whole words", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-punctuation-" }); - yield* writeTextFile(cwd, "src/words.ts", "foo- foo-\nafoo-b\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "foo-", - limit: 100, - caseSensitive: true, - wholeWord: true, - useRegex: true, - }); - - expect(result.matches).toHaveLength(1); - expect(result.matches[0]).toMatchObject({ - path: "src/words.ts", - lineNumber: 1, - matchRanges: [ - { start: 0, end: 4 }, - { start: 5, end: 9 }, - ], - }); - }), - ); - it.effect("caps matches per file so one dense file cannot fill the page", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-per-file-cap-" }); @@ -813,26 +548,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect("preserves invalid regex errors during case-insensitive searches", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-invalid-regex-" }); - yield* writeTextFile(cwd, "src/shapes.ts", "foobar\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "foo)bar(", - limit: 100, - caseSensitive: false, - wholeWord: false, - useRegex: true, - }); - - expect(result.regexFallbackError).toBeDefined(); - expect(result.matches).toEqual([]); - }), - ); - it.effect("maps multi-byte lines to string-indexed ranges", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-multibyte-" }); @@ -857,29 +572,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); describe("browse", () => { - it.effect("returns matching directories and excludes files", () => - Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const path = yield* Path.Path; - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-prefix-" }); - yield* writeTextFile(cwd, "alphabet.txt", "ignore me"); - yield* writeTextFile(cwd, "alpha/index.ts", "export {};\n"); - yield* writeTextFile(cwd, "alpine/index.ts", "export {};\n"); - - const result = yield* workspaceEntries.browse({ - partialPath: path.join(cwd, "alp"), - }); - - expect(result).toEqual({ - parentPath: cwd, - entries: [ - { name: "alpha", fullPath: path.join(cwd, "alpha") }, - { name: "alpine", fullPath: path.join(cwd, "alpine") }, - ], - }); - }), - ); - it.effect("shows dot directories in directory mode and hidden-prefix mode", () => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; @@ -903,56 +595,5 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); }), ); - - it.effect("supports relative paths when cwd is provided", () => - Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const path = yield* Path.Path; - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-relative-" }); - yield* writeTextFile(cwd, "packages/pkg.json", "{}"); - - const result = yield* workspaceEntries.browse({ - cwd, - partialPath: "./pack", - }); - - expect(result).toEqual({ - parentPath: cwd, - entries: [{ name: "packages", fullPath: path.join(cwd, "packages") }], - }); - }), - ); - - it.effect("rejects relative paths without cwd", () => - Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - - const error = yield* workspaceEntries - .browse({ - partialPath: "./src", - }) - .pipe(Effect.flip); - - expect(error._tag).toBe("WorkspaceEntriesCurrentProjectRequiredError"); - expect(error.message).toBe( - "A current project is required to browse relative workspace path './src'.", - ); - }), - ); - - it.effect("returns an empty listing when the OS denies directory access", () => - Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-eacces-" }); - - const denied = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); - vi.mocked(NodeFSP.readdir).mockRejectedValueOnce(denied); - - const result = yield* workspaceEntries.browse({ - partialPath: yield* appendSeparator(cwd), - }); - expect(result).toEqual({ parentPath: cwd, entries: [] }); - }), - ); }); }); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 607db665de71..a0d35202c211 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -14,6 +14,8 @@ import type { ProjectEntry, ProjectListEntriesInput, ProjectListEntriesResult, + ProjectListIgnoredFilesInput, + ProjectListIgnoredFilesResult, ProjectSearchContentsInput, ProjectSearchContentsResult, ProjectSearchEntriesInput, @@ -99,6 +101,9 @@ export class WorkspaceEntries extends Context.Service< readonly search: ( input: ProjectSearchEntriesInput, ) => Effect.Effect; + readonly listIgnoredFiles: ( + input: ProjectListIgnoredFilesInput, + ) => Effect.Effect; readonly searchContents: ( input: ProjectSearchContentsInput, ) => Effect.Effect; @@ -106,6 +111,9 @@ export class WorkspaceEntries extends Context.Service< } >()("t3/workspace/WorkspaceEntries") {} +/** Ignored files offered for copying; more than this is a build folder, not config. */ +const IGNORED_FILES_LIMIT = 200; + /** Paths sent to one `git check-ignore` call when labelling a folder's children. */ const IGNORE_CHECK_CHUNK_SIZE = 1000; @@ -160,20 +168,21 @@ export const make = Effect.gen(function* () { if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, indexKey))) { continue; } - const recoverRefreshFailure = ( - cause: - | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed - | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut - | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, - ) => - Effect.gen(function* () { + const recoverRefreshFailure = Effect.fn("WorkspaceEntries.recoverRefreshFailure")( + function* ( + cause: + | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed + | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut + | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, + ) { yield* Effect.logWarning("Failed to refresh workspace search index", { cwd, variant, cause, }); yield* workspaceSearchIndexes.invalidate(indexKey); - }); + }, + ); yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; yield* searchIndex.refresh(); @@ -387,7 +396,56 @@ export const make = Effect.gen(function* () { }, ); - return WorkspaceEntries.of({ browse, list, refresh, search, searchContents }); + const listIgnoredFiles: WorkspaceEntries["Service"]["listIgnoredFiles"] = Effect.fn( + "WorkspaceEntries.listIgnoredFiles", + )(function* (input) { + const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); + // `--directory` reports a folder holding nothing but ignored files as one + // `name/` entry, which is how `node_modules` and build output drop out. + const result = yield* vcsProcess + .run({ + operation: "WorkspaceEntries.listIgnoredFiles", + command: "git", + args: [ + "-c", + "core.fsmonitor=false", + "ls-files", + "--others", + "--ignored", + "--exclude-standard", + "--directory", + "--no-empty-directory", + "-z", + ], + cwd: normalizedCwd, + allowNonZeroExit: true, + timeoutMs: 10_000, + maxOutputBytes: 4 * 1024 * 1024, + }) + .pipe(Effect.orElseSucceed(() => undefined)); + if (!result || result.exitCode !== 0) return { files: [], truncated: false }; + const paths = result.stdout + .split("\0") + .filter((entry) => entry.length > 0 && !entry.endsWith("/")) + .toSorted(); + const files = yield* Effect.promise(() => + Promise.all( + paths.slice(0, IGNORED_FILES_LIMIT).map(async (relativePath) => { + // lstat: a symlink usually points outside the workspace, where reads refuse to go. + const stat = await NodeFSP.lstat(path.join(normalizedCwd, relativePath)).catch( + () => null, + ); + return stat?.isFile() ? { path: relativePath, byteLength: stat.size } : null; + }), + ), + ); + return { + files: files.filter((file) => file !== null), + truncated: paths.length > IGNORED_FILES_LIMIT, + }; + }); + + return WorkspaceEntries.of({ browse, list, listIgnoredFiles, refresh, search, searchContents }); }); export const layer = Layer.effect(WorkspaceEntries, make).pipe( diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index ae956e69d0cb..9eb2ba758049 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -1,6 +1,8 @@ -// @effect-diagnostics nodeBuiltinImport:off - FileSystem cannot create a FIFO. +// @effect-diagnostics nodeBuiltinImport:off - FileSystem cannot create a FIFO, and reads spy on native realpath. import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -17,6 +19,12 @@ import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import { vi } from "vite-plus/test"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, realpath: vi.fn(actual.realpath) }; +}); // Temp folders count as known workspaces unless named as unknown. const UNKNOWN_WORKSPACE_PREFIX = "t3code-unknown-workspace-"; @@ -69,19 +77,6 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { describe("getMetadata", () => { - it.effect("expands home-relative paths on the server", () => - Effect.gen(function* () { - const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const home = yield* makeTempDir; - yield* writeTextFile(home, "file", "hello"); - expect( - (yield* service - .getMetadata({ paths: ["~/file"] }) - .pipe(Effect.provideService(HostProcess.HomeDirectory, home))).entries, - ).toEqual([{ kind: "file", byteLength: 5 }]); - }), - ); - it.effect( "reports actual kinds for extensionless files, dotfiles, and dotted directories", () => @@ -137,65 +132,76 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); - it.effect.skipIf(!symlinksSupported)("follows file and directory symlinks", () => + // A file chip for `file://attacker.test/s/a.ts` in an agent's message sent + // the Windows user's NTLM credentials to attacker.test as it rendered. + it.effect("never stats a network path", () => Effect.gen(function* () { - const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "target", "text"); - yield* fs.makeDirectory(path.join(cwd, "directory")); - const fileLink = path.join(cwd, "file-link"); - const directoryLink = path.join(cwd, "directory-link"); - yield* fs.symlink(path.join(cwd, "target"), fileLink); - yield* fs.symlink(path.join(cwd, "directory"), directoryLink); - expect((yield* service.getMetadata({ paths: [fileLink, directoryLink] })).entries).toEqual([ - { kind: "file", byteLength: 4 }, - { kind: "directory" }, - ]); + const statted: Array = []; + const service = yield* WorkspaceFileSystem.make.pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fs, + stat: (requestedPath) => { + statted.push(requestedPath); + return fs.stat(requestedPath); + }, + }), + ), + Effect.provide(layerKnownWorkspacesTest), + ); + const paths = [ + "\\\\attacker.test\\s\\a.ts", + "//attacker.test/s/a.ts", + "/\\attacker.test\\s", + ]; + expect((yield* service.getMetadata({ paths })).entries).toEqual([null, null, null]); + expect(statted).toEqual([]); }), ); }); describe("readFile", () => { - it.effect("reads UTF-8 files relative to the workspace root", () => + it.effect("reads host files outside the workspace root by absolute path", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n"); + const outsideDir = yield* makeTempDir; + yield* writeTextFile(outsideDir, "cleanup-report.md", "# Report\n"); + const absolutePath = path.join(outsideDir, "cleanup-report.md"); const result = yield* workspaceFileSystem.readFile({ cwd, - relativePath: "src/index.ts", + relativePath: absolutePath, }); expect(result).toEqual({ - relativePath: "src/index.ts", - contents: "export const answer = 42;\n", - byteLength: 26, + relativePath: absolutePath, + contents: "# Report\n", + byteLength: 9, truncated: false, }); }), ); - it.effect("reads host files outside the workspace root by absolute path", () => + it.effect("reads ~/ paths from the server home outside the workspace", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const path = yield* Path.Path; const cwd = yield* makeTempDir; - const outsideDir = yield* makeTempDir; - yield* writeTextFile(outsideDir, "cleanup-report.md", "# Report\n"); - const absolutePath = path.join(outsideDir, "cleanup-report.md"); + const home = yield* makeTempDir; + yield* writeTextFile(home, ".claude/CLAUDE.md", "# Notes\n"); + yield* writeTextFile(cwd, "~/.claude/CLAUDE.md", "Workspace decoy\n"); - const result = yield* workspaceFileSystem.readFile({ - cwd, - relativePath: absolutePath, - }); + const result = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "~/.claude/CLAUDE.md" }) + .pipe(Effect.provideService(HostProcess.HomeDirectory, home)); expect(result).toEqual({ - relativePath: absolutePath, - contents: "# Report\n", - byteLength: 9, + relativePath: "~/.claude/CLAUDE.md", + contents: "# Notes\n", + byteLength: 8, truncated: false, }); }), @@ -230,21 +236,68 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); - it.effect("rejects reads outside the workspace root", () => + it.effect("refuses a network path outside the workspace", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const cwd = yield* makeTempDir; const error = yield* workspaceFileSystem - .readFile({ cwd, relativePath: "../escape.md" }) + .readFile({ cwd: "//nas.test/share/project", relativePath: "//attacker.test/s/a.md" }) .pipe(Effect.flip); - expect(error.message).toContain( - "Workspace file path must be relative to the project root: ../escape.md", + expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + }), + ); + + // This resolves inside the project, but Windows reads `\\?\` paths as + // written, so realpath on the raw string signed in to attacker.test. + it.effect("reads the network path it checked", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.make.pipe( + Effect.provide(Layer.merge(layerKnownWorkspacesTest, NodePath.layerWin32)), ); + // Rejects without touching the filesystem, so a Windows runner never looks the share up. + const realpath = vi + .mocked(NodeFSP.realpath) + .mockClear() + .mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" })); + + yield* workspaceFileSystem + .readFile({ + cwd: "\\\\?\\UNC\\nas.test\\share\\project", + relativePath: "\\\\?\\UNC\\attacker.test\\s\\..\\..\\nas.test\\share\\project\\a.md", + }) + .pipe(Effect.exit); + + expect(realpath.mock.calls.map(([target]) => target)).toEqual([ + "\\\\?\\UNC\\nas.test\\share\\project\\a.md", + ]); }), ); + // Resolving `..` before realpath would read beside the link instead of its target. + // Windows collapses `..` before the filesystem sees a path, so it has no such reads. + it.effect.skipIf(!symlinksSupported || HostProcess.Platform.defaultValue() === "win32")( + "reads `..` after a link from the link's target", + () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir; + yield* writeTextFile(root, "target/parent.md", "beside the target\n"); + yield* writeTextFile(root, "parent.md", "beside the link\n"); + yield* fs.makeDirectory(path.join(root, "target", "child")); + yield* fs.symlink(path.join(root, "target", "child"), path.join(root, "link")); + + const result = yield* workspaceFileSystem.readFile({ + cwd: root, + relativePath: `${root}/link/../parent.md`, + }); + + expect(result.contents).toBe("beside the target\n"); + }), + ); + it.effect.skipIf(!symlinksSupported)( "rejects symlinks that resolve outside the workspace root", () => @@ -274,30 +327,20 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i resolvedPath, }); expect("cause" in error).toBe(false); - }), - ); - - it.effect("rejects directories without manufacturing an I/O cause", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - yield* fileSystem.makeDirectory(path.join(cwd, "src")); - - const error = yield* workspaceFileSystem - .readFile({ cwd, relativePath: "src" }) - .pipe(Effect.flip); - const resolvedPath = yield* fileSystem.realPath(path.join(cwd, "src")); - expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError); - expect(error).toMatchObject({ - workspaceRoot: cwd, - relativePath: "src", - resolvedPath, - }); - expect("cause" in error).toBe(false); - }), + yield* fileSystem.symlink(outsideDir, path.join(cwd, "linked-dir")); + const missingChildError = yield* workspaceFileSystem + .readFile( + Object.assign( + { cwd, relativePath: "linked-dir/missing/t3.json" }, + { allowMissing: true }, + ), + ) + .pipe(Effect.flip); + expect(missingChildError).toBeInstanceOf( + WorkspaceFileSystem.WorkspaceFilePathEscapeError, + ); + }), ); it.effect("rejects binary files without leaking their contents into the error", () => @@ -324,53 +367,9 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i expect("contents" in error).toBe(false); }), ); - - it.effect("preserves the real cause and path for I/O failures", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - const resolvedPath = path.join(cwd, "missing.txt"); - - const error = yield* workspaceFileSystem - .readFile({ cwd, relativePath: "missing.txt" }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFileSystemOperationError); - expect(error).toMatchObject({ - workspaceRoot: cwd, - relativePath: "missing.txt", - resolvedPath, - operationPath: resolvedPath, - operation: "realpath-target", - }); - expect(error.cause).toBeInstanceOf(Error); - expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT"); - }), - ); }); describe("writeFile", () => { - it.effect("writes files relative to the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem.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("rejects writes outside a known workspace", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 96fe5a0d234f..4e7c81404256 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -13,6 +13,7 @@ import type { } from "@t3tools/contracts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { isNetworkPath } from "@t3tools/shared/path"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -142,7 +143,9 @@ export const make = Effect.gen(function* () { const metadataForPath = Effect.fnUntraced(function* (requestedPath: string) { const expandedPath = expandHomePath(requestedPath, yield* HostProcess.HomeDirectory); - if (!path.isAbsolute(expandedPath)) return null; + // Chips ask as soon as an agent's message renders, and touching a network + // path makes Windows sign in to its host with the user's NTLM credentials. + if (!path.isAbsolute(expandedPath) || isNetworkPath(expandedPath)) return null; const stat = yield* fileSystem.stat(expandedPath).pipe(Effect.option); if (stat._tag === "None") return null; if (stat.value.type === "Directory") return { kind: "directory" } as const; @@ -221,26 +224,49 @@ export const make = Effect.gen(function* () { /** * Workspace-relative paths must stay inside the root, symlinks included. An - * absolute path reads a host file in place, such as a report an agent wrote to a - * temp directory; it gets no root check. + * absolute or `~/` path reads a host file in place, such as a report an agent + * wrote to a temp directory; it gets no root check. A network path does, since + * Windows signs in to its host with the user's NTLM credentials. */ const resolveReadTarget = Effect.fn("WorkspaceFileSystem.resolveReadTarget")(function* ( input: ProjectReadFileInput, ) { const requestedPath = input.relativePath.trim(); - if (path.isAbsolute(requestedPath)) { + const hostPath = requestedPath.startsWith("~/") + ? expandHomePath(requestedPath, yield* HostProcess.HomeDirectory) + : requestedPath; + if ( + isNetworkPath(hostPath) && + !KnownWorkspaces.isWithinRoot(path, path.resolve(hostPath), path.resolve(input.cwd)) + ) { + return yield* new WorkspacePaths.WorkspacePathOutsideRootError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + } + if (path.isAbsolute(hostPath)) { + // Native realpath hands Windows a `\\?\` path as written, `..` included, + // so a network path must be the resolved one the check above approved. + // Others keep POSIX `link/..` semantics, which resolving first would change. + const absolutePath = isNetworkPath(hostPath) ? path.resolve(hostPath) : hostPath; const realTargetPath = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(requestedPath), + try: () => NodeFSP.realpath(absolutePath), catch: (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, - resolvedPath: requestedPath, - operationPath: requestedPath, + resolvedPath: absolutePath, + operationPath: absolutePath, operation: "realpath-target", cause, }), - }); + }).pipe( + Effect.catchIf( + (error) => + input.allowMissing === true && (error.cause as NodeJS.ErrnoException).code === "ENOENT", + () => Effect.succeed(null), + ), + ); return { relativePath: requestedPath, realTargetPath }; } @@ -261,8 +287,36 @@ export const make = Effect.gen(function* () { cause, }), }); - const realTargetPath = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(target.absolutePath), + const resolved = yield* Effect.tryPromise({ + try: async () => { + let candidate = target.absolutePath; + for (;;) { + try { + return { + realTargetPath: path.join( + await NodeFSP.realpath(candidate), + path.relative(candidate, target.absolutePath), + ), + exists: candidate === target.absolutePath, + }; + } catch (error) { + if (!input.allowMissing || (error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + // Like writes, missing reads must check the nearest existing ancestor. + // An existing entry that cannot resolve is a dangling link, not absence. + const exists = await NodeFSP.lstat(candidate).then( + () => true, + (cause: NodeJS.ErrnoException) => { + if (cause.code !== "ENOENT") throw cause; + return false; + }, + ); + if (exists || path.dirname(candidate) === candidate) throw error; + candidate = path.dirname(candidate); + } + } + }, catch: (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, @@ -273,22 +327,33 @@ export const make = Effect.gen(function* () { cause, }), }); - if (!isInsideRealRoot(realWorkspaceRoot, realTargetPath)) { + if (!isInsideRealRoot(realWorkspaceRoot, resolved.realTargetPath)) { return yield* new WorkspaceFilePathEscapeError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedWorkspaceRoot: realWorkspaceRoot, - resolvedPath: realTargetPath, + resolvedPath: resolved.realTargetPath, }); } - return { relativePath: target.relativePath, realTargetPath }; + return { + relativePath: target.relativePath, + realTargetPath: resolved.exists ? resolved.realTargetPath : null, + }; }); const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( "WorkspaceFileSystem.readFile", )(function* (input) { const target = yield* resolveReadTarget(input); + const absentResult = { + relativePath: target.relativePath, + contents: "", + byteLength: 0, + truncated: false, + exists: false, + }; const realTargetPath = target.realTargetPath; + if (realTargetPath === null) return absentResult; return yield* Effect.acquireUseRelease( Effect.tryPromise({ @@ -308,9 +373,16 @@ export const make = Effect.gen(function* () { operation: "open", cause, }), - }), + }).pipe( + Effect.catchIf( + (error) => + input.allowMissing === true && (error.cause as NodeJS.ErrnoException).code === "ENOENT", + () => Effect.succeed(null), + ), + ), (handle) => Effect.gen(function* () { + if (handle === null) return absentResult; const stat = yield* Effect.tryPromise({ try: () => handle.stat(), catch: (cause) => @@ -362,18 +434,20 @@ export const make = Effect.gen(function* () { }; }), (handle) => - Effect.tryPromise({ - try: () => handle.close(), - catch: (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: realTargetPath, - operationPath: realTargetPath, - operation: "close", - cause, + handle === null + ? Effect.void + : Effect.tryPromise({ + try: () => handle.close(), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "close", + cause, + }), }), - }), ); }); diff --git a/apps/server/src/workspace/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts index ecaf6116c67a..574904b34356 100644 --- a/apps/server/src/workspace/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/WorkspacePaths.test.ts @@ -4,7 +4,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as WorkspacePaths from "./WorkspacePaths.ts"; @@ -20,47 +19,8 @@ const makeTempDir = Effect.fn("makeTempDir")(function* () { }); }); -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(layerTest)("WorkspacePathsLive", (it) => { describe("normalizeWorkspaceRoot", () => { - it.effect("resolves an existing directory", () => - Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths.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.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("creates missing directories when createIfMissing is enabled", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; @@ -78,114 +38,9 @@ it.layer(layerTest)("WorkspacePathsLive", (it) => { expect(stat.type).toBe("Directory"); }), ); - - it.effect("rejects file paths", () => - Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths.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:"); - }), - ); - - it.effect("preserves non-NotFound stat failures while validating the root", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const workspacePaths = yield* WorkspacePaths.make.pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fileSystem, - stat: (path) => - Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "stat", - pathOrDescriptor: String(path), - description: "Test PermissionDenied stat failure.", - }), - ), - }), - ); - const path = yield* Path.Path; - const workspaceRoot = " ./permission-denied "; - const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim()); - - const error = yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe(Effect.flip); - - expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError); - expect(error).toMatchObject({ - workspaceRoot, - normalizedWorkspaceRoot, - phase: "validate-existing", - }); - }), - ); - - it.effect("preserves stat failures while verifying a newly created root", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - let statCalls = 0; - const workspacePaths = yield* WorkspacePaths.make.pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fileSystem, - stat: (path) => { - statCalls += 1; - const reason = statCalls === 1 ? "NotFound" : "PermissionDenied"; - return Effect.fail( - PlatformError.systemError({ - _tag: reason, - module: "FileSystem", - method: "stat", - pathOrDescriptor: String(path), - description: `Test ${reason} stat failure.`, - }), - ); - }, - makeDirectory: () => Effect.void, - }), - ); - const path = yield* Path.Path; - const workspaceRoot = " ./created-then-unreadable "; - const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim()); - - const error = yield* workspacePaths - .normalizeWorkspaceRoot(workspaceRoot, { createIfMissing: true }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError); - expect(error).toMatchObject({ - workspaceRoot, - normalizedWorkspaceRoot, - phase: "verify-created", - }); - }), - ); }); describe("resolveRelativePathWithinRoot", () => { - it.effect("resolves relative paths inside the workspace root", () => - Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths.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.WorkspacePaths; diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 2c61bbe76501..6b748e19396e 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -107,7 +107,7 @@ function toPosixRelativePath(input: string): string { return input.replaceAll("\\", "/"); } -export const make = Effect.gen(function* () { +const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.native.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.native.test.ts deleted file mode 100644 index f9526112af3f..000000000000 --- a/apps/server/src/workspace/WorkspaceSearchIndex.native.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import * as NodeModule from "node:module"; -import type { Result } from "@ff-labs/fff-node"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; - -const requireForFff = NodeModule.createRequire(import.meta.url); -const { FileFinder } = requireForFff("@ff-labs/fff-node") as typeof import("@ff-labs/fff-node"); - -function valueOf(result: Result): T { - if (!result.ok) throw new Error(result.error); - return result.value; -} - -it.effect("decodes native search and grep strings and keeps scores only through 200 results", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const basePath = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-fff-native-" }); - const names = [ - ...Array.from({ length: 205 }, (_, index) => `file-${String(index).padStart(3, "0")}`), - "日本語", - "café", - "emoji-🦊", - ]; - const relativePaths = names.map((name) => `${name}/${name}.ts`); - const content = "prefix marker-日本語🦊\n"; - yield* Effect.forEach( - names, - (name) => - Effect.gen(function* () { - yield* fileSystem.makeDirectory(path.join(basePath, name)); - yield* fileSystem.writeFileString(path.join(basePath, name, `${name}.ts`), content); - }), - { concurrency: "unbounded" }, - ); - expect(requireForFff.resolve("@ff-labs/fff-node").replaceAll("\\", "/")).toMatch( - /\/dist\/index\.js$/u, - ); - const finder = yield* Effect.acquireRelease( - Effect.sync(() => - valueOf(FileFinder.create({ basePath, disableWatch: true, disableMmapCache: true })), - ), - (finder) => Effect.sync(() => finder.destroy()), - ); - expect(valueOf(yield* Effect.promise(() => finder.waitForIndexReady(10_000)))).toBe(true); - - const files200 = valueOf(finder.fileSearch("", { pageSize: 200 })); - const files201 = valueOf(finder.fileSearch("", { pageSize: 201 })); - expect(files200.items).toHaveLength(200); - expect(files200.scores).toHaveLength(200); - expect(files201.items).toHaveLength(201); - expect(files201.scores).toEqual([]); - expect(files201.items.slice(0, 200)).toEqual(files200.items); - const files = valueOf(finder.fileSearch("", { pageSize: 1000 })); - expect(files.items.map((item) => item.relativePath.normalize("NFC")).toSorted()).toEqual( - relativePaths.toSorted(), - ); - for (const item of files.items) { - expect(item.fileName.normalize("NFC")).toBe( - path.basename(item.relativePath).normalize("NFC"), - ); - expect(item.size).toBe(Buffer.byteLength(content)); - expect(Number.isFinite(item.modified)).toBe(true); - } - const exact = valueOf(finder.fileSearch("日本語.ts", { pageSize: 1 })); - expect(exact.items[0]?.fileName.normalize("NFC")).toBe("日本語.ts"); - expect(exact.scores[0]).toMatchObject({ exactMatch: true, matchType: "exact_filename" }); - - const dirs200 = valueOf(finder.directorySearch("", { pageSize: 200 })); - const dirs201 = valueOf(finder.directorySearch("", { pageSize: 201 })); - expect(dirs200.scores).toHaveLength(200); - expect(dirs201.items).toHaveLength(201); - expect(dirs201.scores).toEqual([]); - expect(dirs201.items.slice(0, 200)).toEqual(dirs200.items); - const directories = valueOf(finder.directorySearch("", { pageSize: 1000 })); - expect( - directories.items - .map((item) => item.relativePath.normalize("NFC")) - .filter(Boolean) - .toSorted(), - ).toEqual(names.map((name) => `${name}/`).toSorted()); - - const mixed200 = valueOf(finder.mixedSearch("", { pageSize: 200 })); - const mixed201 = valueOf(finder.mixedSearch("", { pageSize: 201 })); - expect(mixed200.scores).toHaveLength(200); - expect(mixed201.items).toHaveLength(201); - expect(mixed201.scores).toEqual([]); - expect(mixed201.items.slice(0, 200)).toEqual(mixed200.items); - const mixed = valueOf(finder.mixedSearch("", { pageSize: 1000 })); - expect(mixed.items.filter((item) => item.type === "file").map(({ item }) => item)).toEqual( - files.items, - ); - expect(mixed.items.filter((item) => item.type === "directory").map(({ item }) => item)).toEqual( - directories.items, - ); - - const grep = valueOf(finder.grep("marker", { mode: "plain", pageSize: 1000 })); - expect(grep.items.map((item) => item.relativePath.normalize("NFC")).toSorted()).toEqual( - relativePaths.toSorted(), - ); - for (const item of grep.items) { - expect(item.fileName.normalize("NFC")).toBe( - path.basename(item.relativePath).normalize("NFC"), - ); - expect(item.lineContent).toBe(content.trimEnd()); - expect(item.lineNumber).toBe(1); - expect(item.col).toBe(7); - expect(item.matchRanges).toEqual([[7, 13]]); - } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 4a3e80e92ecc..2c41e4ed0b3d 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -6,9 +6,7 @@ import { type GrepResult, } from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import { vi } from "vite-plus/test"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -65,279 +63,6 @@ it.effect("filters image searches before applying the result limit", () => ), ); -it.effect("preserves unexpected FileFinder creation failures", () => - Effect.gen(function* () { - const cause = new Error("native initialization failed"); - vi.spyOn(FileFinder, "create").mockImplementationOnce(() => { - throw cause; - }); - - const error = yield* Effect.flip( - Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")), - ); - - expect(error).toMatchObject({ - _tag: "WorkspaceSearchIndexCreateFailed", - cwd: "/workspace/project", - reason: "FileFinder.create threw unexpectedly.", - cause, - }); - }), -); - -it.effect("keeps returned FileFinder creation diagnostics out of the cause chain", () => - Effect.gen(function* () { - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ - ok: false, - error: "native index rejected the directory", - }); - - const error = yield* Effect.flip( - Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")), - ); - - expect(error).toMatchObject({ - _tag: "WorkspaceSearchIndexCreateFailed", - cwd: "/workspace/project", - reason: "native index rejected the directory", - }); - expect(error.cause).toBeUndefined(); - }), -); - -it.effect("waits for the full content index warmup before returning", () => - Effect.gen(function* () { - const waitForIndexReady = vi.fn(async () => ({ ok: true as const, value: true })); - const finder = { - destroy: vi.fn(), - waitForIndexReady, - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")); - - expect(waitForIndexReady).toHaveBeenCalledWith(15_000); - }), -); - -it.effect("preserves a full-index warmup timeout as a structured error", () => - Effect.gen(function* () { - const finder = { - destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })), - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const error = yield* Effect.flip( - Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")), - ); - - expect(error).toMatchObject({ - _tag: "WorkspaceSearchIndexScanTimedOut", - cwd: "/workspace/project", - timeout: "15 seconds", - }); - }), -); - -it.effect("returns partial path results when the initial scan times out", () => - Effect.scoped( - Effect.gen(function* () { - const mixedSearch = vi.fn(() => ({ - ok: true as const, - value: { - items: [ - { - type: "directory" as const, - item: { relativePath: "src", fileName: "src", fileCount: 1 }, - }, - { type: "file" as const, item: fileItem("src/index.ts") }, - ], - scores: [], - totalMatched: 2, - totalFiles: 1, - }, - })); - let scanning = true; - const finder = { - destroy: vi.fn(), - isScanning: vi.fn(() => scanning), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })), - mixedSearch, - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "paths"); - const list = yield* searchIndex.list(); - const search = yield* searchIndex.search("src", 10); - scanning = true; - mixedSearch.mockImplementationOnce(() => { - scanning = false; - return { - ok: true as const, - value: { - items: [ - { - type: "directory" as const, - item: { relativePath: "src", fileName: "src", fileCount: 1 }, - }, - { type: "file" as const, item: fileItem("src/index.ts") }, - ], - scores: [], - totalMatched: 2, - totalFiles: 1, - }, - }; - }); - const searchCompletingDuringQuery = yield* searchIndex.search("src", 10); - scanning = false; - const completedSearch = yield* searchIndex.search("src", 10); - - expect(list).toEqual({ - entries: [ - { kind: "directory", path: "src" }, - { kind: "file", path: "src/index.ts" }, - ], - truncated: true, - }); - expect(search.truncated).toBe(true); - expect(searchCompletingDuringQuery.truncated).toBe(true); - expect(completedSearch.truncated).toBe(false); - expect(mixedSearch).toHaveBeenCalledTimes(4); - }), - ), -); - -it.effect("preserves FileFinder destroy failures as structured defects", () => - Effect.gen(function* () { - const cause = new Error("native destroy failed"); - const finder = { - destroy: vi.fn(() => { - throw cause; - }), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const exit = yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")).pipe( - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.hasDies(exit.cause)).toBe(true); - const error = Cause.squash(exit.cause); - expect(error).toBeInstanceOf(WorkspaceSearchIndex.WorkspaceSearchIndexDestroyFailed); - expect(error).toMatchObject({ - _tag: "WorkspaceSearchIndexDestroyFailed", - cwd: "/workspace/project", - cause, - }); - } - }), -); - -it.effect("preserves search and refresh failures with operation context", () => - Effect.scoped( - Effect.gen(function* () { - const searchCause = new Error("native search failed"); - const refreshCause = new Error("native scan failed"); - const contentSearchCause = new Error("native grep failed"); - const finder = { - destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - mixedSearch: vi.fn(() => { - throw searchCause; - }), - grep: vi.fn(() => { - throw contentSearchCause; - }), - scanFiles: vi.fn(() => { - throw refreshCause; - }), - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); - const query = "authorization: Bearer secret-token"; - const searchError = yield* Effect.flip(searchIndex.search(query, 3)); - const contentSearchError = yield* Effect.flip( - searchIndex.searchContents({ - query, - limit: 3, - caseSensitive: false, - wholeWord: false, - useRegex: false, - }), - ); - const refreshError = yield* Effect.flip(searchIndex.refresh()); - - expect(searchError).toMatchObject({ - _tag: "WorkspaceSearchIndexSearchFailed", - cwd: "/workspace/project", - queryLength: query.length, - pageSize: 4, - reason: "FileFinder.mixedSearch threw unexpectedly.", - cause: searchCause, - }); - expect(searchError).not.toHaveProperty("query"); - expect(searchError.message).not.toMatch(/Bearer|secret-token/); - expect(contentSearchError).toMatchObject({ - _tag: "WorkspaceSearchIndexSearchFailed", - cwd: "/workspace/project", - queryLength: query.length, - pageSize: 3, - reason: "FileFinder.grep threw unexpectedly.", - cause: contentSearchCause, - }); - expect(contentSearchError).not.toHaveProperty("query"); - expect(contentSearchError.message).not.toMatch(/Bearer|secret-token/); - expect(refreshError).toMatchObject({ - _tag: "WorkspaceSearchIndexRefreshFailed", - cwd: "/workspace/project", - reason: "FileFinder.scanFiles threw unexpectedly.", - cause: refreshCause, - }); - }), - ), -); - -it.effect("keeps returned search diagnostics out of the cause chain", () => - Effect.scoped( - Effect.gen(function* () { - const finder = { - destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })), - scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })), - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); - const query = "authorization: Bearer secret-token"; - const searchError = yield* Effect.flip(searchIndex.search(query, 3)); - const refreshError = yield* Effect.flip(searchIndex.refresh()); - - expect(searchError).toMatchObject({ - _tag: "WorkspaceSearchIndexSearchFailed", - cwd: "/workspace/project", - queryLength: query.length, - pageSize: 4, - reason: "native query rejected", - }); - expect(searchError).not.toHaveProperty("query"); - expect(searchError.message).not.toMatch(/Bearer|secret-token/); - expect(searchError.cause).toBeUndefined(); - expect(refreshError).toMatchObject({ - _tag: "WorkspaceSearchIndexRefreshFailed", - cwd: "/workspace/project", - reason: "native refresh rejected", - }); - expect(refreshError.cause).toBeUndefined(); - }), - ), -); - it.effect("continues whole-word searches after a filtered grep page", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/workspace/fileHeaderMimeType.test.ts b/apps/server/src/workspace/fileHeaderMimeType.test.ts deleted file mode 100644 index 9fe13cfd01d7..000000000000 --- a/apps/server/src/workspace/fileHeaderMimeType.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { fileHeaderMimeType } from "./fileHeaderMimeType.ts"; - -describe("fileHeaderMimeType", () => { - it.each([ - [[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], "image/png"], - [[0xff, 0xd8, 0xff], "image/jpeg"], - [[0x50, 0x4b, 0x03, 0x04], "application/zip"], - [[0x52, 0x49, 0x46, 0x46, 0xc2, 0xa0, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50], "image/webp"], - [[0x89, 0x50, 0x4e], undefined], - ])("classifies binary header %j", (header, expected) => { - expect(fileHeaderMimeType(new Uint8Array(header))).toBe(expected); - }); - - it.each([ - ["%PDF-1.7", "application/pdf"], - ["GIF89a", "image/gif"], - ["SQLite format 3\0", "application/vnd.sqlite3"], - ["RIFF1234WEBP", "image/webp"], - ["#!/usr/bin/env python3\n", "text/x-python"], - ["#!/usr/bin/node\n", "text/javascript"], - ["#!/bin/bash\n", "text/x-shellscript"], - ["#!/usr/bin/env ruby\n", "text/x-ruby"], - ["hello", undefined], - ["", undefined], - ["#!/usr/bin/env custom\n", undefined], - ])("classifies %j", (header, expected) => { - expect(fileHeaderMimeType(new TextEncoder().encode(header))).toBe(expected); - }); -}); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index a2a9656b3f3c..a9a028ec94bd 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1,29 +1,100 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { - ORCHESTRATION_PROTOCOL_VERSION, + AuthSessionId, + EnvironmentId, ProjectId, ThreadId, + WS_METHODS, + WsSubscribeServerConfigRpc, type OrchestrationV2StoredEvent, type OrchestrationProjectShell, type ServerConfig, type ServerConfigStreamEvent, } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; -import { ChildProcessSpawner } from "effect/process"; +import { Headers } from "effect/http"; +import { RpcGroup, RpcMessage, RpcServer } from "effect/rpc"; import * as SqlClient from "effect/sql/SqlClient"; +import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; +import * as ServerBrowser from "./preview/ServerBrowser.ts"; +import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; +import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; +import * as AgentSessionImporter from "./project/AgentSessionImporter.ts"; +import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; +import * as AntigravityInstallation from "./provider/AntigravityInstallation.ts"; +import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; +import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; +import * as CommandOutputHub from "./orchestration-v2/CommandOutputHub.ts"; +import * as CodexInstallation from "./provider/CodexInstallation.ts"; +import * as DeviceService from "./device/DeviceService.ts"; +import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; +import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import * as EnvironmentTheme from "./environmentTheme.ts"; +import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; +import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts"; +import * as McpAppRequests from "./mcpApps/McpAppRequests.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; +import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; +import * as OtlpEndpointCheck from "./observability/OtlpEndpointCheck.ts"; +import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; +import * as PortScanner from "./preview/PortScanner.ts"; +import * as PreviewManager from "./preview/Manager.ts"; +import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; +import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; +import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as ProviderAuthService from "./provider/ProviderAuthService.ts"; +import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; +import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; +import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; +import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; +import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts"; +import * as PullRequestService from "./pullRequest/PullRequestService.ts"; +import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; +import * as RelayClient from "@t3tools/shared/relayClient"; +import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; +import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; +import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as ReviewService from "./review/ReviewService.ts"; +import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; +import * as SecretRequests from "./secrets/SecretRequests.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; +import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; +import * as SessionStore from "./auth/SessionStore.ts"; +import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; +import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; +import * as StorageCleanup from "./storageCleanup.ts"; +import * as DatabaseStorage from "./persistence/DatabaseStorage.ts"; +import * as TerminalManager from "./terminal/Manager.ts"; +import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; +import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; +import * as UsageService from "./usage/UsageService.ts"; +import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; +import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; +import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; +import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; +import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; +import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; +import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +import * as ServerConfigModule from "./config.ts"; +import * as Keybindings from "./keybindings.ts"; +import * as ServerSettings from "./serverSettings.ts"; + import * as EventStore from "./orchestration-v2/EventStore.ts"; import * as ProjectionMaintenance from "./orchestration-v2/ProjectionMaintenance.ts"; import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; @@ -35,51 +106,13 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as ProjectEnrichment from "./project/ProjectEnrichmentService.ts"; import * as ProjectService from "./project/ProjectService.ts"; import { - hasCompatibleOrchestrationProtocol, - resolveAvailableEditorsForConfig, - shouldUseBoundedThreadSnapshot, + layerWsRpc, subscribeOrchestrationV2Shell, subscribeOrchestrationV2Thread, + WS_RPC_SERVER_OPTIONS, withLateEditorConfig, } from "./ws.ts"; -it("accepts only the current orchestration protocol before websocket RPC setup", () => { - assert.isTrue( - hasCompatibleOrchestrationProtocol( - new URL(`https://host.test/ws?orchestrationProtocol=${ORCHESTRATION_PROTOCOL_VERSION}`), - ), - ); - assert.isFalse(hasCompatibleOrchestrationProtocol(new URL("https://host.test/ws"))); - assert.isFalse( - hasCompatibleOrchestrationProtocol( - new URL(`https://host.test/ws?orchestrationProtocol=${ORCHESTRATION_PROTOCOL_VERSION - 1}`), - ), - ); -}); - -it("keeps full thread snapshot fallback unless the client opts into bounded history", () => { - assert.isFalse(shouldUseBoundedThreadSnapshot({})); - assert.isFalse(shouldUseBoundedThreadSnapshot({ acceptBoundedSnapshot: false })); - assert.isTrue(shouldUseBoundedThreadSnapshot({ acceptBoundedSnapshot: true })); -}); - -it.effect("does not block server config when editor discovery never resolves", () => - Effect.gen(function* () { - const discoveryInterrupted = yield* Deferred.make(); - const responseFiber = yield* resolveAvailableEditorsForConfig( - Effect.never.pipe( - Effect.onInterrupt(() => Deferred.succeed(discoveryInterrupted, undefined)), - ), - ).pipe(Effect.forkChild); - - yield* TestClock.adjust(Duration.seconds(5)); - - const availableEditors = yield* Fiber.join(responseFiber); - yield* Deferred.await(discoveryInterrupted); - assert.deepEqual(availableEditors, []); - }), -); - const snapshotConfig = (fields: Partial) => ({ availableEditors: [], settings: {}, ...fields }) as unknown as ServerConfig; @@ -119,21 +152,6 @@ it.effect("resends late editors without rolling back updates already sent", () = }), ); -it.effect("sends no late snapshot when the scan matches the snapshot", () => - Effect.gen(function* () { - const events = yield* withLateEditorConfig( - snapshotConfig({ availableEditors: ["vscode"] }), - Stream.empty, - { - resolveAvailableEditors: () => Effect.succeed(["vscode"]), - resolveFileManagerRevealKind: () => Effect.succeed(undefined), - }, - ).pipe(Stream.runCollect); - - assert.equal(events.length, 0); - }), -); - it.effect("resends a file manager reveal kind that missed the snapshot", () => Effect.gen(function* () { const events = yield* withLateEditorConfig( @@ -154,99 +172,6 @@ it.effect("resends a file manager reveal kind that missed the snapshot", () => }), ); -const makeParkedWindowsLauncher = Effect.gen(function* () { - const parkedProbes = yield* Queue.unbounded(); - const release = yield* Deferred.make(); - const launcher = yield* ExternalLauncher.make.pipe( - Effect.provide( - Layer.mergeAll( - FileSystem.layerNoop({ - stat: () => - Queue.offer(parkedProbes, undefined).pipe( - Effect.andThen(Deferred.await(release)), - Effect.as({ type: "File" } as FileSystem.File.Info), - ), - }), - Path.layer, - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected spawn")), - ), - ), - ), - ); - const onWindows = (effect: Effect.Effect) => - effect.pipe( - Effect.provideService(HostProcess.Platform, "win32"), - Effect.provide( - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { PATH: "C:\\t3-late-editors-test", PATHEXT: ".EXE" }, - }), - ), - ), - ); - return { - editors: { - resolveAvailableEditors: () => onWindows(launcher.resolveAvailableEditors()), - resolveFileManagerRevealKind: () => onWindows(launcher.resolveFileManagerRevealKind()), - }, - probeParked: Queue.take(parkedProbes), - releaseProbes: Deferred.succeed(release, undefined), - }; -}); - -it.effect("recovers editors after a real scan outlasts the config timeout", () => - Effect.gen(function* () { - const { editors, probeParked, releaseProbes } = yield* makeParkedWindowsLauncher; - - const snapshotFiber = yield* resolveAvailableEditorsForConfig( - editors.resolveAvailableEditors(), - ).pipe(Effect.forkChild); - yield* probeParked; - yield* TestClock.adjust(Duration.seconds(5)); - const snapshotEditors = yield* Fiber.join(snapshotFiber); - assert.deepEqual(snapshotEditors, []); - - const lateFiber = yield* withLateEditorConfig( - snapshotConfig({ availableEditors: snapshotEditors }), - Stream.empty, - editors, - ).pipe(Stream.runCollect, Effect.forkChild); - yield* releaseProbes; - - const [late] = Array.from(yield* Fiber.join(lateFiber)); - assert.equal(late?.type, "snapshot"); - if (late?.type === "snapshot") { - assert.equal(late.config.availableEditors.includes("vscode"), true); - } - }).pipe(Effect.scoped), -); - -it.effect("recovers a reveal kind whose real probe outlasts the config timeout", () => - Effect.gen(function* () { - const { editors, probeParked, releaseProbes } = yield* makeParkedWindowsLauncher; - - const lateFiber = yield* withLateEditorConfig( - snapshotConfig({ availableEditors: ["file-manager"] }), - Stream.empty, - { - resolveAvailableEditors: () => Effect.succeed(["file-manager"]), - resolveFileManagerRevealKind: editors.resolveFileManagerRevealKind, - }, - ).pipe(Stream.runCollect, Effect.forkChild); - yield* probeParked; - yield* TestClock.adjust(Duration.seconds(6)); - yield* releaseProbes; - - const [late] = Array.from(yield* Fiber.join(lateFiber)); - assert.equal(late?.type, "snapshot"); - if (late?.type === "snapshot") { - assert.equal(late.config.shellRevealInFileManagerKind, "file-explorer"); - } - }).pipe(Effect.scoped), -); - const ReplayTestLayer = OrchestrationEventStore.layer.pipe( Layer.provideMerge(SqlitePersistence.layerMemory), ); @@ -363,6 +288,252 @@ it.live.each(["thread", "shell"] as const)( }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); +// Build the production RPC layer; unrelated capabilities fail if this test calls them. +const layerUnusedConfigRpcServices = Layer.mergeAll( + Layer.mock(ServerSecretStore.ServerSecretStore)({}), + Layer.mock(WorkspacePaths.WorkspacePaths)({}), + Layer.mock(TraceDiagnostics.TraceDiagnostics)({}), + Layer.mock(ProjectFaviconResolver.ProjectFaviconResolver)({}), + Layer.mock(ProjectEnrichment.ProjectEnrichmentService)({}), + Layer.mock(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator)({}), + Layer.mock(AcpRegistrySupport.AcpRegistryCatalog)({}), + Layer.mock(AgentSessionImporter.AgentSessionImporter)({}), + Layer.mock(AgentSessionScanner.AgentSessionScanner)({}), + Layer.mock(AnalyticsService.AnalyticsService)({}), + Layer.mock(AntigravityInstallation.AntigravityInstallation)({ managedDirectory: "/unused" }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), + Layer.mock(CheckpointDiffQuery.CheckpointDiffQuery)({}), + Layer.mock(CommandOutputHub.CommandOutputHub)({}), + Layer.mock(CodexInstallation.CodexInstallation)({ managedDirectory: "/unused" }), + Layer.mock(DeviceService.DeviceService)({}), + Layer.mock(EnvironmentTheme.EnvironmentThemeService)({}), + Layer.mock(GitWorkflowService.GitWorkflowService)({}), + Layer.mock(HostResources.HostResources)({}), + Layer.mock(McpAppRequests.McpAppRequests)({}), + Layer.mock(ModelManifest.ModelManifest)({}), + Layer.mock(OrchestrationEventStore.OrchestrationEventStore)({}), + Layer.mock(Orchestrator.OrchestratorV2)({}), + Layer.mock(OtlpEndpointCheck.OtlpEndpointCheck)({}), + Layer.mock(PairingGrantStore.PairingGrantStore)({}), + Layer.mock(PortScanner.PortDiscovery)({}), + Layer.mock(PreviewManager.PreviewManager)({}), + Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({}), + Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({}), + Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({}), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + Layer.mock(ProviderAuthService.ProviderAuthService)({}), + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({}), + Layer.mock(ProviderLatestVersions.ProviderLatestVersions)({}), + Layer.mock(ProviderMaintenanceRunner.ProviderMaintenanceRunner)({}), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({}), + Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}), + Layer.mock(PullRequestService.PullRequestService)({}), + Layer.mock(PullRequestSyncReactor.PullRequestSyncReactor)({}), + Layer.mock(RelayClient.RelayClient)({}), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({}), + Layer.mock(ResourceTelemetry.ResourceTelemetry)({}), + Layer.mock(ReviewService.ReviewService)({}), + Layer.mock(ScheduledTasks.ScheduledTaskService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({}), + Layer.mock(ServerRuntimeStartup.ServerRuntimeStartup)({}), + Layer.mock(ServerSelfUpdate.ServerSelfUpdate)({}), + Layer.mock(SessionStore.SessionStore)({ cookieName: "config-test", legacyCookieName: undefined }), + Layer.mock(SourceControlDiscovery.SourceControlDiscovery)({}), + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}), + Layer.mock(StorageCleanup.StorageCleanup)({}), + Layer.mock(DatabaseStorage.DatabaseStorage)({}), + Layer.mock(TerminalManager.TerminalManager)({}), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ThreadManagement.ThreadManagementService)({}), + Layer.mock(ThreadSearch.ThreadSearch)({}), + Layer.mock(UsageService.UsageService)({}), + Layer.mock(VcsProvisioningService.VcsProvisioningService)({}), + Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({}), + Layer.mock(WorkspaceEntries.WorkspaceEntries)({}), + Layer.mock(WorkspaceFileSystem.WorkspaceFileSystem)({}), + Layer.mock(WorktreeSetupTracker.WorktreeSetupTracker)({}), +); + +const layerConfigSources = Layer.mergeAll( + Keybindings.layer, + ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer)), +).pipe( + Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfigModule.layerTest(process.cwd(), { prefix: "t3-config-subscription-test-" }), + ), + ), + Layer.provideMerge(NodeServices.layer), +); + +it.effect( + "preserves settings during discovery and keybindings during snapshot ACK at the config RPC boundary", + () => + Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + const settings = yield* ServerSettings.ServerSettingsService; + const keybindingsSubscribed = yield* Deferred.make(); + const settingsSubscribed = yield* Deferred.make(); + const keybindingsChanges = keybindings.subscribeChanges.pipe( + Effect.tap(() => Deferred.succeed(keybindingsSubscribed, undefined)), + ); + const settingsChanges = settings.subscribeChanges.pipe( + Effect.tap(() => Deferred.succeed(settingsSubscribed, undefined)), + ); + const initialKeybindings = (yield* keybindings.getSnapshot).keybindings; + const discoveryStarted = yield* Deferred.make(); + const finishDiscovery = yield* Deferred.make(); + const responses = yield* Queue.unbounded(); + const configRpcServices = Layer.mergeAll( + layerUnusedConfigRpcServices, + Layer.mock(ExternalLauncher.ExternalLauncher)({ + resolveAvailableEditors: () => + Deferred.succeed(discoveryStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishDiscovery)), + Effect.as([]), + ), + resolveFileManagerRevealKind: () => Effect.succeed(undefined), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + streamChanges: Stream.never, + }), + Layer.mock(UsageLimitSources.UsageLimitSources)({ streamChanges: Stream.make([]) }), + Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ + resolveTargets: () => Effect.succeed([]), + }), + Layer.mock(DirectEndpoints.DirectEndpoints)({ resolve: () => Effect.succeed([]) }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + scratchRoot: Effect.succeedNone, + namedProjectsRoot: process.cwd(), + }), + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getDescriptor: Effect.succeed({ + environmentId: EnvironmentId.make("config-test"), + label: "Config test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0", + capabilities: { repositoryIdentity: false }, + }), + }), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + getDescriptor: () => + Effect.succeed({ + policy: "loopback-browser", + bootstrapMethods: [], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "config-test-session", + }), + }), + // Signal subscription on either accessor so the test also fails, rather + // than hangs, against a handler that subscribes after the snapshot. + Layer.succeed(Keybindings.Keybindings, { + ...keybindings, + streamChanges: Stream.unwrap(keybindingsChanges), + subscribeChanges: keybindingsChanges, + }), + Layer.succeed(ServerSettings.ServerSettingsService, { + ...settings, + streamChanges: Stream.unwrap(settingsChanges), + subscribeChanges: settingsChanges, + }), + ); + const server = yield* RpcServer.makeNoSerialization( + RpcGroup.make(WsSubscribeServerConfigRpc), + { + ...WS_RPC_SERVER_OPTIONS, + onFromServer: (response) => + response._tag === "Chunk" + ? Effect.forEach(response.values, (event) => Queue.offer(responses, event), { + discard: true, + }) + : Effect.void, + }, + ).pipe( + Effect.provide( + layerWsRpc( + { + sessionId: AuthSessionId.make("config-test"), + subject: "config-test", + method: "browser-session-cookie", + scopes: [], + }, + {}, + {}, + yield* PreviewAutomationBroker.PreviewAutomationBroker, + yield* ServerBrowser.ServerBrowser, + ).pipe(Layer.provide(configRpcServices)), + ), + ); + const requestId = RpcMessage.RequestId("config"); + const acknowledge = server.write(0, { _tag: "Ack", requestId }); + yield* server.write(0, { + _tag: "Request", + id: requestId, + tag: WS_METHODS.subscribeServerConfig, + payload: {}, + headers: Headers.empty, + }); + yield* Deferred.await(discoveryStarted); + + const duringDiscovery = yield* settings.updateSettings({ defaultAutoPull: true }); + yield* Deferred.succeed(finishDiscovery, undefined); + const snapshot = yield* Queue.take(responses); + assert.equal(snapshot.type, "snapshot"); + if (snapshot.type !== "snapshot") return yield* Effect.die("Missing initial snapshot"); + assert.isFalse(snapshot.config.settings.defaultAutoPull); + assert.deepEqual(snapshot.config.keybindings, initialKeybindings); + const duringAck = yield* keybindings.upsertKeybindingRule({ + key: "mod+alt+y", + command: "terminal.toggle", + }); + yield* acknowledge; + yield* Deferred.await(keybindingsSubscribed); + yield* Deferred.await(settingsSubscribed); + + // Later updates expose a dropped buffered event as a value mismatch, not a timeout. + const latestSettings = yield* settings.updateSettings({ enableProviderUpdateChecks: false }); + const latestKeybindings = yield* keybindings.upsertKeybindingRule({ + key: "mod+alt+z", + command: "terminal.split", + }); + const expectedSettings = [duringDiscovery, latestSettings].map( + ServerSettings.redactServerSettingsForClient, + ); + const expectedKeybindings = [duringAck, latestKeybindings]; + // The two sources can interleave, but each must retain its own publication order. + for (let i = 0; i < 4; i++) { + const event = yield* Queue.take(responses); + if (event.type === "settingsUpdated") { + assert.deepEqual(event.payload.settings, expectedSettings.shift()); + } else { + assert.equal(event.type, "keybindingsUpdated"); + if (event.type !== "keybindingsUpdated") + return yield* Effect.die("Unexpected config event"); + assert.deepEqual(event.payload.keybindings, expectedKeybindings.shift()); + assert.deepEqual(event.payload.issues, []); + } + yield* acknowledge; + } + assert.isEmpty(expectedSettings); + assert.isEmpty(expectedKeybindings); + yield* server.write(0, { _tag: "Interrupt", requestId, interruptors: [] }); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.fresh(layerConfigSources), + Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), + Layer.mock(ServerBrowser.ServerBrowser)({}), + ), + ), + Effect.scoped, + ), +); + // Compaction keeps the oldest and newest event per message, so a cursor older // than the deleted rows still replays each message where the client would // first have seen it (reducers append on first sight) and reaches the same diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 52eef686f090..ca12e0a24fe9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -21,7 +21,6 @@ import { rpcInitialItems } from "./rpcInitialItems.ts"; import { subscribeChatGptHandoff } from "./provider/CodexChatGptHandoff.ts"; import { subscribeCodexAuthCallback } from "./provider/CodexAuthCallback.ts"; import { - DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, AcpRegistryOperationError, CommandId, authScopeResponse, @@ -92,15 +91,11 @@ import { type ProviderDriverKind, type ProviderInstanceId, ThreadId, - type TerminalAttachStreamEvent, - type TerminalError, - type TerminalEvent, type TerminalMetadataStreamEvent, type PullRequestRef, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; -import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { HttpRouter, HttpServerRequest, @@ -126,6 +121,7 @@ import * as SecretRequests from "./secrets/SecretRequests.ts"; import { archivedShellStreamItemFromThreadShell, buildActiveShellSnapshot, + bufferShellLiveStream, coalesceShellApplicationEvents, coalesceStoredThreadEvents, composeShellStreamWithEnrichment, @@ -168,6 +164,7 @@ import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRe import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; +import { latestProviderVersionCacheKeys } from "@t3tools/provider-core/server/maintenanceResolver"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ProviderAuthService from "./provider/ProviderAuthService.ts"; import { makeProviderInstallation } from "./provider/providerInstallation.ts"; @@ -178,7 +175,7 @@ import * as ServerSettings from "./serverSettings.ts"; import * as DatabaseStorage from "./persistence/DatabaseStorage.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as TerminalManager from "./terminal/Manager.ts"; -import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; +import { terminalOutputStream, withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as ServerBrowser from "./preview/ServerBrowser.ts"; import * as DeviceService from "./device/DeviceService.ts"; @@ -206,6 +203,7 @@ import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts"; import * as ProjectService from "./project/ProjectService.ts"; +import { threadWorkspaceRoot } from "./project/threadWorkspaceRoot.ts"; import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts"; import { projectMutationOperation } from "./project/ProjectMutation.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; @@ -216,6 +214,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as DefectReporter from "./observability/DefectReporter.ts"; +import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForDeviceList, rpcAuthorizationError } from "./auth/RpcAuthorization.ts"; @@ -227,6 +226,7 @@ import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import { featureUsage } from "./telemetry/FeatureUsage.ts"; +import type { TelemetryOwner } from "./telemetry/Identify.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; @@ -246,7 +246,9 @@ import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; +import { isNetworkPath } from "@t3tools/shared/path"; import * as RelayClient from "@t3tools/shared/relayClient"; +import { isTelemetryIdHash } from "@t3tools/shared/telemetryId"; import { sameUsageLimitCommandCoverage, withUsageLimitsCommands, @@ -267,7 +269,7 @@ const resolveDiscoveryForConfig = ( Effect.map(Option.getOrElse(onTimeout)), ); -export const resolveAvailableEditorsForConfig = ( +const resolveAvailableEditorsForConfig = ( discovery: Effect.Effect, E, R>, ) => resolveDiscoveryForConfig(discovery, () => []); @@ -280,24 +282,23 @@ type EditorDiscovery = Pick< "resolveAvailableEditors" | "resolveFileManagerRevealKind" >; -const resolveEditorConfig = ( +const resolveEditorConfig = Effect.fn("ws.resolveEditorConfig")(function* ( availableEditors: ReadonlyArray, revealKind: Effect.Effect, -) => - Effect.gen(function* () { - const fileManagerRevealKind = availableEditors.includes("file-manager") - ? yield* revealKind - : undefined; - return { - availableEditors, - ...(fileManagerRevealKind === undefined - ? {} - : { - shellRevealInFileManager: true, - shellRevealInFileManagerKind: fileManagerRevealKind, - }), - }; - }); +) { + const fileManagerRevealKind = availableEditors.includes("file-manager") + ? yield* revealKind + : undefined; + return { + availableEditors, + ...(fileManagerRevealKind === undefined + ? {} + : { + shellRevealInFileManager: true, + shellRevealInFileManagerKind: fileManagerRevealKind, + }), + }; +}); export const withLateEditorConfig = ( config: ClientServerConfig, @@ -602,14 +603,14 @@ const MAX_CLIENT_APP_VERSION_LENGTH = 64; const MAX_CLIENT_BROWSER_LENGTH = 64; const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80; -export function hasCompatibleOrchestrationProtocol(url: URL): boolean { +function hasCompatibleOrchestrationProtocol(url: URL): boolean { return ( url.searchParams.get(ORCHESTRATION_PROTOCOL_QUERY_PARAM) === String(ORCHESTRATION_PROTOCOL_VERSION) ); } -export function shouldUseBoundedThreadSnapshot(input: { +function shouldUseBoundedThreadSnapshot(input: { readonly acceptBoundedSnapshot?: boolean; }): boolean { return input.acceptBoundedSnapshot === true; @@ -678,6 +679,28 @@ function readClientAnalyticsProps(request: HttpServerRequest.HttpServerRequest) }; } +/** + * Owners a connecting client reports: its hashed T3 Connect account and its + * hashed device id. Only hashes are accepted, so a raw id never reaches analytics. + */ +function readClientOwners( + request: HttpServerRequest.HttpServerRequest, +): ReadonlyArray { + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return []; + } + const params = url.value.searchParams; + return ( + [ + ["connect", params.get("clientOwner")], + ["device", params.get("clientDevice")], + ] as const + ).flatMap(([source, ownerId]) => + ownerId !== null && isTelemetryIdHash(ownerId) ? [{ source, ownerId }] : [], + ); +} + const canReplayPersistedRange = Effect.fnUntraced(function* ( afterSequence: number, headSequence: number, @@ -1024,7 +1047,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc ); const liveFrom = (afterSequence: number) => - bufferLiveStream( + bufferShellLiveStream( toShellStream( applicationEvents.streamProjectedApplicationEvents({ afterSequence, @@ -1158,7 +1181,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc }, ); -const layerWsRpc = ( +export const layerWsRpc = ( currentSession: EnvironmentAuth.AuthenticatedSession, clientOrigin: OrchestrationClientOrigin, clientAnalyticsProps: Readonly>, @@ -1181,6 +1204,7 @@ const layerWsRpc = ( const projectStore = yield* ProjectStore.ProjectStoreV2; const projectService = yield* ProjectService.ProjectService; const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; + const eventLoopMonitor = yield* EventLoopMonitor.EventLoopMonitor; const threadSearch = yield* ThreadSearch.ThreadSearch; const providerSessionsV2 = yield* ProviderSessionManager.ProviderSessionManagerV2; @@ -1229,6 +1253,7 @@ const layerWsRpc = ( const projectCloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker; const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const projectEnrichment = yield* ProjectEnrichmentService.ProjectEnrichmentService; const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; const agentSessionImporter = yield* AgentSessionImporter.AgentSessionImporter; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; @@ -1283,16 +1308,6 @@ const layerWsRpc = ( const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; const otlpEndpointCheck = yield* OtlpEndpointCheck.OtlpEndpointCheck; - const automaticGitFetchInterval = serverSettings.getSettings.pipe( - Effect.map( - (settings) => resolveServerBackgroundActivitySettings(settings).automaticGitFetchInterval, - ), - Effect.catch((cause) => - Effect.logWarning("Failed to read automatic Git fetch interval setting", { - detail: cause.message, - }).pipe(Effect.as(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), - ), - ); const sourceControlRepositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; const withPullRequestViewer = pullRequests.withRoutingCredential; @@ -1653,65 +1668,66 @@ const layerWsRpc = ( ), ); - const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => - Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.loadConfigState; - const currentProviders = yield* providerRegistry.getProviders; - const providers = options.usageLimitsCommand - ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) - : currentProviders; - const settings = ServerSettings.redactServerSettingsForClient( - yield* serverSettings.getSettings, - ); - const environment = yield* serverEnvironment.getDescriptor; - const auth = yield* serverAuth.getDescriptor(); - const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; - const editorConfig = yield* resolveEditorConfig( - yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()), - resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()), - ); + const loadServerConfig = Effect.fn("ws.loadServerConfig")(function* (options: { + readonly usageLimitsCommand: boolean; + }) { + const keybindingsConfig = yield* keybindings.loadConfigState; + const currentProviders = yield* providerRegistry.getProviders; + const providers = options.usageLimitsCommand + ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) + : currentProviders; + const settings = ServerSettings.redactServerSettingsForClient( + yield* serverSettings.getSettings, + ); + const environment = yield* serverEnvironment.getDescriptor; + const auth = yield* serverAuth.getDescriptor(); + const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; + const lastEventLoopStall = yield* eventLoopMonitor.latestNoticeableStall; + const editorConfig = yield* resolveEditorConfig( + yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()), + resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()), + ); - return { - environment, - auth, - ...(config.publicUrl ? { publicUrl: config.publicUrl.toString() } : {}), - cwd: config.cwd, - keybindingsConfigPath: config.keybindingsConfigPath, - keybindings: keybindingsConfig.keybindings, - issues: keybindingsConfig.issues, - providers, - ...editorConfig, - remoteOpenTargets: yield* resolveAvailableEditorsForConfig( - remoteOpenTargets.resolveTargets(), - ), - directEndpoints: yield* resolveAvailableEditorsForConfig(directEndpoints.resolve()), - observability: { - logsDirectoryPath: config.logsDir, - localTracingEnabled: true, - ...(config.otlpTracesUrl !== undefined - ? { otlpTracesUrl: config.otlpTracesUrl } - : {}), - otlpTracesEnabled: config.otlpTracesUrl !== undefined, - ...(config.otlpMetricsUrl !== undefined - ? { otlpMetricsUrl: config.otlpMetricsUrl } - : {}), - otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, - ...(config.otlpLogsUrl !== undefined ? { otlpLogsUrl: config.otlpLogsUrl } : {}), - otlpLogsEnabled: config.otlpLogsUrl !== undefined, - }, - settings, - shellResumeCompletionMarker: true, - threadResumeCompletionMarker: true, - threadSnapshotPagination: true, - threadFind: true, - threadFindProgressive: true, - ...Option.match(scratchWorkspaceRoot, { - onNone: () => ({}), - onSome: (root) => ({ scratchWorkspaceRoot: root }), - }), - newProjectsRoot: managedFolders.namedProjectsRoot, - }; - }); + return { + environment, + auth, + ...(config.publicUrl ? { publicUrl: config.publicUrl.toString() } : {}), + cwd: config.cwd, + keybindingsConfigPath: config.keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + providers, + ...editorConfig, + remoteOpenTargets: yield* resolveAvailableEditorsForConfig( + remoteOpenTargets.resolveTargets(), + ), + directEndpoints: yield* resolveAvailableEditorsForConfig(directEndpoints.resolve()), + observability: { + logsDirectoryPath: config.logsDir, + localTracingEnabled: true, + ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), + otlpTracesEnabled: config.otlpTracesUrl !== undefined, + ...(config.otlpMetricsUrl !== undefined + ? { otlpMetricsUrl: config.otlpMetricsUrl } + : {}), + otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, + ...(config.otlpLogsUrl !== undefined ? { otlpLogsUrl: config.otlpLogsUrl } : {}), + otlpLogsEnabled: config.otlpLogsUrl !== undefined, + }, + settings, + shellResumeCompletionMarker: true, + threadResumeCompletionMarker: true, + threadSnapshotPagination: true, + threadFind: true, + threadFindProgressive: true, + ...Option.match(scratchWorkspaceRoot, { + onNone: () => ({}), + onSome: (root) => ({ scratchWorkspaceRoot: root }), + }), + newProjectsRoot: managedFolders.namedProjectsRoot, + ...(lastEventLoopStall === undefined ? {} : { lastEventLoopStall }), + }; + }); const refreshGitStatus = (cwd: string) => vcsStatusBroadcaster @@ -2169,8 +2185,9 @@ const layerWsRpc = ( }), ), ), - [WS_METHODS.serverRefreshProviders]: (input) => - Effect.gen(function* () { + [WS_METHODS.serverRefreshProviders]: + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (input) { if (input.refreshModels) { yield* modelManifest.forceRefresh; const instances = yield* providerInstances.listInstances; @@ -2183,10 +2200,16 @@ const layerWsRpc = ( Effect.gen(function* () { yield* instance.invalidateCaches ?? Effect.void; const maintenance = yield* instance.snapshot.resolveMaintenance({ - fresh: true, + fresh: + input.instanceId !== undefined && + input.cwd !== undefined && + input.fresh !== true, }); - if (maintenance.packageName) - yield* providerLatestVersions.invalidate(maintenance.packageName); + yield* Effect.forEach( + latestProviderVersionCacheKeys(maintenance), + providerLatestVersions.invalidate, + { discard: true }, + ); }), { concurrency: "unbounded", discard: true }, ); @@ -2238,59 +2261,65 @@ const layerWsRpc = ( [WS_METHODS.mcpAppsToolInfo]: (input) => mcpAppRequests.toolInfo(input), [WS_METHODS.mcpAppsUpdateModelContext]: (input) => mcpAppRequests.updateModelContext(input), [WS_METHODS.mcpAppsReadResource]: (input) => mcpAppRequests.readResource(input), - [WS_METHODS.providerUploadFeedback]: (input) => - Effect.gen(function* () { - const projection = yield* threadManagement.getThreadRecords(input.threadId, [ - "providerThreads", - ]); - const providerThread = - projection.providerThreads.find( - (candidate) => candidate.id === projection.thread.activeProviderThreadId, - ) ?? projection.providerThreads.at(-1); - const providerSessionId = providerThread?.providerSessionId ?? null; - if (providerThread === undefined || providerSessionId === null) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause: "No provider session has run in this thread yet.", - }), - ); - } - const runtime = Option.getOrNull(yield* providerSessionsV2.get(providerSessionId)); - if (runtime === null) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause: "The provider session is no longer running. Send a message first.", - }), - ); - } - if (runtime.uploadFeedback === undefined) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause: `Provider '${runtime.driver}' does not support feedback uploads.`, - }), - ); - } - return yield* runtime.uploadFeedback({ - providerThread, - ...(input.reason === undefined ? {} : { reason: input.reason }), - }); - }).pipe( - Effect.mapError((cause) => - isProviderUploadFeedbackError(cause) - ? cause - : new ProviderUploadFeedbackError({ + [WS_METHODS.providerUploadFeedback]: + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced( + function* (input) { + const projection = yield* threadManagement.getThreadRecords(input.threadId, [ + "providerThreads", + ]); + const providerThread = + projection.providerThreads.find( + (candidate) => candidate.id === projection.thread.activeProviderThreadId, + ) ?? projection.providerThreads.at(-1); + const providerSessionId = providerThread?.providerSessionId ?? null; + if (providerThread === undefined || providerSessionId === null) { + return yield* Effect.fail( + new ProviderUploadFeedbackError({ threadId: input.threadId, - cause, + cause: "No provider session has run in this thread yet.", }), - ), + ); + } + const runtime = Option.getOrNull(yield* providerSessionsV2.get(providerSessionId)); + if (runtime === null) { + return yield* Effect.fail( + new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause: "The provider session is no longer running. Send a message first.", + }), + ); + } + if (runtime.uploadFeedback === undefined) { + return yield* Effect.fail( + new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause: `Provider '${runtime.driver}' does not support feedback uploads.`, + }), + ); + } + return yield* runtime.uploadFeedback({ + providerThread, + ...(input.reason === undefined ? {} : { reason: input.reason }), + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError((cause) => + isProviderUploadFeedbackError(cause) + ? cause + : new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause, + }), + ), + ), ), [WS_METHODS.serverUpdateProvider]: (input) => providerMaintenanceRunner.updateProvider(input), - [WS_METHODS.providerConsumeResetCredit]: (input) => - Effect.gen(function* () { + [WS_METHODS.providerConsumeResetCredit]: + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (input) { if ("sourceId" in input) return yield* usageLimitSources.consumeResetCredit(input); const instance = yield* providerInstances.getInstance(input.instanceId); if (instance === undefined || !instance.enabled) { @@ -2345,13 +2374,15 @@ const layerWsRpc = ( ServerSelfUpdate.updateProgressStream(serverSelfUpdate, input), [WS_METHODS.serverCommitDesktopUpdate]: (input) => serverSelfUpdate.commitDesktopUpdate(input.requestId), - [WS_METHODS.serverUpsertKeybinding]: (rule) => - Effect.gen(function* () { + [WS_METHODS.serverUpsertKeybinding]: + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (rule) { const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); return { keybindings: keybindingsConfig, issues: [] }; }), - [WS_METHODS.serverRemoveKeybinding]: (rule) => - Effect.gen(function* () { + [WS_METHODS.serverRemoveKeybinding]: + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (rule) { const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule); return { keybindings: keybindingsConfig, issues: [] }; }), @@ -2360,8 +2391,9 @@ const layerWsRpc = ( [WS_METHODS.serverGetDatabaseStorage]: () => databaseStorage.status, [WS_METHODS.serverGetSettings]: (_input) => serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), - [WS_METHODS.serverUpdateSettings]: ({ patch, providerInstanceMutation }) => - Effect.gen(function* () { + [WS_METHODS.serverUpdateSettings]: + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* ({ patch, providerInstanceMutation }) { const deviceHosts = patch.deviceHosts ? yield* remoteSshDeviceHosts(patch.deviceHosts).pipe( Effect.provide(deviceHostContext), @@ -2469,6 +2501,8 @@ const layerWsRpc = ( withPullRequestViewer(input, pullRequests.diffFileContents(input)), [WS_METHODS.pullRequestsFilesViewed]: (input) => withPullRequestViewer(input, pullRequests.filesViewed(input)), + [WS_METHODS.pullRequestsChangeBreakdown]: (input) => + withPullRequestViewer(input, pullRequests.changeBreakdown(input)), [WS_METHODS.pullRequestsSetFilesViewed]: (input) => withPullRequestViewer(input, pullRequests.setFilesViewed(input)), [WS_METHODS.pullRequestsRunAction]: (input) => @@ -2553,6 +2587,10 @@ const layerWsRpc = ( }), ), Effect.andThen(refreshGitStatus(project.workspaceRoot)), + // The folder was empty when the project was created, so the cached + // enrichment says "no repository" and clients cannot group it yet. + Effect.andThen(projectEnrichment.invalidate([project.workspaceRoot])), + Effect.andThen(projectEnrichment.request(project.workspaceRoot)), Effect.ignoreCause({ log: true }), ), }), @@ -2617,6 +2655,17 @@ const layerWsRpc = ( }), ), ), + [WS_METHODS.projectsListIgnoredFiles]: (input) => + workspaceEntries.listIgnoredFiles(input).pipe( + Effect.mapError( + (cause) => + new ProjectListEntriesError({ + ...input, + ...projectEntriesFailureContext(cause), + cause, + }), + ), + ), [WS_METHODS.projectsReadFile]: (input) => workspaceFileSystem.readFile(input).pipe( Effect.mapError( @@ -2672,15 +2721,19 @@ const layerWsRpc = ( [WS_METHODS.agentSessionsScan]: () => agentSessionScanner.scan, [WS_METHODS.agentSessionsImport]: (input) => agentSessionImporter.importRecentAgentThreads(input), - [WS_METHODS.assetsCreateUrl]: (input) => - Effect.gen(function* () { + [WS_METHODS.assetsCreateUrl]: + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (input) { const path = yield* Path.Path; if ( input.resource._tag === "attachment" || input.resource._tag === "native-app-icon" || input.resource._tag === "tool-output-image" || input.resource._tag === "github-media" || - (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path)) + // A network path is served only from the thread's project, looked up below. + (input.resource._tag === "media-file" && + path.isAbsolute(input.resource.path) && + !isNetworkPath(input.resource.path)) ) { return yield* issueAssetUrl({ resource: input.resource }); } @@ -2715,10 +2768,9 @@ const layerWsRpc = ( ...(project.value.faviconPath ? { projectFaviconPath: project.value.faviconPath } : {}), - projectCheckoutPending: - clone !== null && - clone.phase !== "done" && - clone.destinationPath === project.value.workspaceRoot, + ...(clone !== null && clone.destinationPath === project.value.workspaceRoot + ? { projectCheckout: clone.phase === "done" ? "cloned" : "pending" } + : {}), }); } const thread = yield* threadManagement @@ -2753,10 +2805,7 @@ const layerWsRpc = ( }), [WS_METHODS.assetsPersistChatAttachments]: (input) => persistChatAttachments(input).pipe(Effect.map((attachments) => ({ attachments }))), - [WS_METHODS.subscribeVcsStatus]: (input) => - vcsStatusBroadcaster.streamStatus(input, { - automaticRemoteRefreshInterval: automaticGitFetchInterval, - }), + [WS_METHODS.subscribeVcsStatus]: (input) => vcsStatusBroadcaster.streamStatus(input), [WS_METHODS.subscribeWorktreeSetup]: (input) => worktreeSetupTracker.stream(input.threadId), [WS_METHODS.worktreeSetupCancel]: (input) => worktreeSetupTracker @@ -2831,11 +2880,11 @@ const layerWsRpc = ( [WS_METHODS.reviewGetDiffFileContents]: (input) => review.getDiffFileContents(input), [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input), [WS_METHODS.terminalAttach]: (input) => - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.attachStream(input, (event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), - ).pipe(Effect.catchCause((cause) => Queue.failCause(queue, cause))), + terminalOutputStream( + (listener, registerCleanup) => + terminalManager.attachStream(input, listener, registerCleanup), + (listener, registerCleanup) => + terminalManager.resyncStream(input, listener, registerCleanup), ), [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input), [WS_METHODS.terminalResize]: (input) => terminalManager.resize(input), @@ -2843,19 +2892,14 @@ const layerWsRpc = ( [WS_METHODS.terminalRestart]: (input) => terminalManager.restart(input), [WS_METHODS.terminalClose]: (input) => terminalManager.close(input), [WS_METHODS.terminalObserve]: (input) => - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.observeStream(input, (event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), - ).pipe(Effect.catchCause((cause) => Queue.failCause(queue, cause))), + terminalOutputStream( + (listener, registerCleanup) => + terminalManager.observeStream(input, listener, registerCleanup), + (listener, registerCleanup) => + terminalManager.resyncStream(input, listener, registerCleanup), ), [WS_METHODS.subscribeTerminalEvents]: (_input) => - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.subscribe((event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), - ), - ), + terminalOutputStream(terminalManager.subscribe), [WS_METHODS.subscribeTerminalMetadata]: (_input) => Stream.callback((queue) => Effect.acquireRelease( @@ -2876,6 +2920,7 @@ const layerWsRpc = ( [WS_METHODS.subscribePreviewEvents]: (_input) => previewManager.events, [WS_METHODS.deviceConfigure]: (input) => deviceService.configure(input), [WS_METHODS.deviceTestHost]: (input) => deviceService.testHost(input), + [WS_METHODS.deviceEasAccount]: (input) => deviceService.easAccount(input), [WS_METHODS.deviceList]: (input) => input.inspectOnly && !input.updateTool ? deviceService.inspect @@ -2887,7 +2932,10 @@ const layerWsRpc = ( ? deviceService.retryHost(input.retryHostId) : deviceService.list, ), - [WS_METHODS.deviceOpen]: (input) => deviceService.open(input), + [WS_METHODS.deviceOpen]: (input) => + threadWorkspaceRoot(input.threadId).pipe( + Effect.flatMap((workspaceRoot) => deviceService.open(input, { workspaceRoot })), + ), [WS_METHODS.deviceClose]: (input) => deviceService.close(input), [WS_METHODS.deviceShutdown]: (input) => deviceService.shutdown(input), [WS_METHODS.deviceDetail]: (input) => deviceService.detail(input), @@ -2923,8 +2971,11 @@ const layerWsRpc = ( Stream.unwrap( Effect.gen(function* () { const usageLimitsCommand = input.usageLimitsCommand === true; + // Buffer changes through discovery and the initial snapshot's RPC ACK wait. + const keybindingsChanges = yield* keybindings.subscribeChanges; + const settingsChanges = yield* serverSettings.subscribeChanges; const config = yield* loadServerConfig({ usageLimitsCommand }); - const keybindingsUpdates = keybindings.streamChanges.pipe( + const keybindingsUpdates = keybindingsChanges.pipe( Stream.map((event) => ({ version: 1 as const, type: "keybindingsUpdated" as const, @@ -2990,7 +3041,7 @@ const layerWsRpc = ( })), ) : Stream.empty; - const settingsUpdates = serverSettings.streamChanges.pipe( + const settingsUpdates = settingsChanges.pipe( Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), Stream.map((settings) => ({ version: 1 as const, @@ -3134,6 +3185,7 @@ export const layer = Layer.unwrap( const clientAnalyticsProps = readClientAnalyticsProps(request); yield* sessions.recordClientConnection(session.sessionId, clientOrigin); yield* analytics.record("client.connected", clientAnalyticsProps); + yield* analytics.recordOwners(readClientOwners(request), "client"); const rpcWebSocketHttpEffect = yield* Effect.gen(function* () { const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket; yield* RpcServer.make(ServerWsRpcGroup, WS_RPC_SERVER_OPTIONS).pipe( diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 958c7f95cd98..e4e1b71c6152 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -3,6 +3,7 @@ import { defineConfig, mergeConfig } from "vite-plus"; import baseConfig from "../../vite.config.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; +import rootPackageJson from "../../package.json" with { type: "json" }; import packageJson from "./package.json" with { type: "json" }; import { WeightedShardSequencer } from "./src/testUtils/weightedShardSequencer.ts"; @@ -34,7 +35,6 @@ if (!TELEMETRY_CHANNELS.includes(telemetryChannel)) { // executable needs a host Node that supports `--build-sea` (25.7+), so this is // a separate mode rather than a second entry in the default build. const packExecutable = process.env.T3CODE_PACK_EXE === "1"; -const SEA_NODE_VERSION = "26.8.2"; const SEA_TARGETS = { "darwin-arm64": { platform: "darwin", arch: "arm64" }, "darwin-x64": { platform: "darwin", arch: "x64" }, @@ -44,6 +44,12 @@ const SEA_TARGETS = { "win-x64": { platform: "win", arch: "x64" }, } as const; const packExecutableTarget = process.env.T3CODE_PACK_EXE_TARGET?.trim(); +const cliNodeVersion = rootPackageJson.engines.node; +if (packExecutable && !packExecutableTarget && process.versions.node !== cliNodeVersion) { + throw new Error( + `Native CLI executables must be built with Node ${cliNodeVersion}; running ${process.versions.node}.`, + ); +} if (packExecutableTarget && !Object.hasOwn(SEA_TARGETS, packExecutableTarget)) { throw new Error( `T3CODE_PACK_EXE_TARGET must be one of ${Object.keys(SEA_TARGETS).join(", ")}, got "${packExecutableTarget}".`, @@ -53,13 +59,17 @@ const packExecutableTargets = packExecutableTarget ? [ { ...SEA_TARGETS[packExecutableTarget as keyof typeof SEA_TARGETS], - nodeVersion: SEA_NODE_VERSION, + nodeVersion: cliNodeVersion, }, ] : undefined; +// The root config's `projects` route repo-root runs to package configs, +// including this one. Inheriting them would make this config a container. +const { projects: _rootTestProjects, ...baseTestConfig } = baseConfig.test ?? {}; + export default mergeConfig( - baseConfig, + { ...baseConfig, test: baseTestConfig }, defineConfig({ run: { tasks: { diff --git a/apps/web/package.json b/apps/web/package.json index 09107e5d126d..1fc7fd6eb5fa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,14 +1,14 @@ { "name": "@t3tools/web", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "scripts": { "build:ghostty-wasm": "bash scripts/build-libghostty-wasm.sh", "dev": "vp dev", + "dev:built": "vp build --watch", "build": "vp build", "preview": "vp preview", - "typecheck": "tsc-rs --noEmit", "test": "vp test run --passWithNoTests --project unit --project browser" }, "dependencies": { @@ -45,6 +45,7 @@ "@tiptap/extension-code-block": "^3.31.3", "@tiptap/extension-heading": "^3.31.3", "@tiptap/extension-horizontal-rule": "^3.31.3", + "@tiptap/extension-italic": "^3.31.3", "@tiptap/extension-list": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", "@tiptap/extension-task-list": "^3.31.3", @@ -61,16 +62,20 @@ "jose": "catalog:", "jsonc-parser": "3.3.1", "jszip": "3.10.1", + "katex": "^0.16.47", "lucide": "^0.564.0", "lucide-react": "^0.564.0", - "mermaid": "^11.17.2", "morphicons": "^1.7.1", + "pele": "^1.8.0", "react": "catalog:", "react-dom": "catalog:", "react-markdown": "^10.1.0", "remark-gfm": "catalog:", + "remark-math": "6.0.0", + "remark-parse": "catalog:", "tailwind-merge": "^3.4.0", "unbash": "^5.0.0", + "unified": "catalog:", "use-sync-external-store": "^1.6.0", "zustand": "^5.0.11" }, @@ -87,12 +92,11 @@ "@types/use-sync-external-store": "^0.0.6", "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.1.2", - "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-playwright": "5.0.5", "compression": "^1.8.2", "oxc-transform-react": "~0.152.0", "playwright": "1.60.0", "tailwindcss": "^4.0.0", - "unified": "catalog:", "vite": "catalog:", "vite-plus": "catalog:" } diff --git a/apps/web/scripts/warm-dep-cache.ts b/apps/web/scripts/warm-dep-cache.ts index 004eb9b53eeb..1b3bbee03464 100644 --- a/apps/web/scripts/warm-dep-cache.ts +++ b/apps/web/scripts/warm-dep-cache.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off - setup-script bootstrap, runs before any Effect runtime exists. +// @effect-diagnostics nodeBuiltinImport:off - plain Node script that dev-runner spawns on a cold cache. /** * The cache cannot be shared between worktrees: Vite's config hash includes * the absolute project root, so each worktree must warm its own. diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index df610d8e1ab8..cc3b9e5d3b45 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -1,9 +1,14 @@ import { RouterProvider } from "@tanstack/react-router"; +import { useEffect } from "react"; + +import { subscribeComposerDraftModelReconciliation } from "./composerDraftStore"; +import { environmentThreadShells } from "./state/threads"; +import { environmentShell } from "./state/shell"; import { BrowserProfileReporter } from "./browser/BrowserProfileReporter"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; -import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; +import { appAtomRegistry, AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; /** @@ -12,6 +17,16 @@ import type { AppRouter } from "./router"; * as routed UI. */ export function AppRoot({ router }: { readonly router: AppRouter }) { + useEffect( + () => + subscribeComposerDraftModelReconciliation({ + registry: appAtomRegistry, + threadShellAtom: environmentThreadShells.threadShellAtom, + threadRefsAtom: environmentThreadShells.threadRefsAtom, + shellStateAtom: environmentShell.stateAtom, + }), + [], + ); return ( diff --git a/apps/web/src/appearanceContrast.test.ts b/apps/web/src/appearanceContrast.test.ts deleted file mode 100644 index 3e6c1fad0448..000000000000 --- a/apps/web/src/appearanceContrast.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -import { applyAppearanceContrast } from "./appearanceContrast"; - -function makeRoot() { - const setProperty = vi.fn(); - return { - root: { style: { setProperty } } as unknown as HTMLElement, - setProperty, - }; -} - -describe("applyAppearanceContrast", () => { - it("boosts semantic contrast above the default", () => { - const { root, setProperty } = makeRoot(); - - applyAppearanceContrast(root, 135); - - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "35%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "8.75%"); - }); - - it("supports the maximum contrast boost", () => { - const { root, setProperty } = makeRoot(); - - applyAppearanceContrast(root, 200); - - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "100%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "25%"); - }); - - it("softens semantic contrast below the default", () => { - const { root, setProperty } = makeRoot(); - - applyAppearanceContrast(root, 70); - - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "70%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%"); - }); - - it("disables contrast mixing at the default", () => { - const { root, setProperty } = makeRoot(); - - applyAppearanceContrast(root, 100); - - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%"); - }); -}); diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts deleted file mode 100644 index 31a2f1d779c5..000000000000 --- a/apps/web/src/appearanceFonts.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - areFontAdvancesMonospace, - clampCodeFontSize, - clampInterfaceFontSize, - clampPromptFontSize, - DEFAULT_CODE_FONT_STACK, - DEFAULT_SANS_FONT_STACK, - appearanceFontStack, - cssFontFamilies, - resolveDefaultFamilyLabel, - resolveTerminalFontPreference, - resolveTerminalFontSizePreference, -} from "./appearanceFonts"; - -describe("areFontAdvancesMonospace", () => { - it("accepts a fixed advance and rejects any proportional glyph", () => { - expect(areFontAdvancesMonospace([10, 10, 10, 10])).toBe(true); - expect(areFontAdvancesMonospace([10, 10, 7, 10])).toBe(false); - expect(areFontAdvancesMonospace([10, 10.02])).toBe(false); - }); - - it("fails open when canvas metrics are unavailable", () => { - expect(areFontAdvancesMonospace([])).toBe(true); - expect(areFontAdvancesMonospace([Number.NaN, Number.NaN])).toBe(true); - }); -}); - -describe("cssFontFamilies", () => { - it("returns null for effectively empty input", () => { - expect(cssFontFamilies("")).toBeNull(); - expect(cssFontFamilies(" ")).toBeNull(); - expect(cssFontFamilies(" , , ")).toBeNull(); - }); - - it("quotes names with spaces and keeps single idents bare", () => { - expect(cssFontFamilies("Fira Code")).toBe('"Fira Code"'); - expect(cssFontFamilies("monospace")).toBe("monospace"); - expect(cssFontFamilies('"Comic Mono"')).toBe('"Comic Mono"'); - }); - - it("normalizes comma-separated lists and strips embedded quotes", () => { - expect(cssFontFamilies(" Fira Code , Menlo ")).toBe('"Fira Code", Menlo'); - expect(cssFontFamilies('Bad"Name')).toBe('"BadName"'); - }); - - it("quotes names that are not single CSS idents", () => { - expect(cssFontFamilies("3270 Nerd Font")).toBe('"3270 Nerd Font"'); - expect(cssFontFamilies("M+ 1m")).toBe('"M+ 1m"'); - }); -}); - -describe("resolveDefaultFamilyLabel", () => { - it("skips generic keywords and returns null for a stack of only generics", () => { - expect(resolveDefaultFamilyLabel("system-ui, sans-serif")).toBeNull(); - expect(resolveDefaultFamilyLabel("ui-monospace, monospace")).toBeNull(); - }); -}); - -describe("appearanceFontStack", () => { - it("prepends the custom family to the default stack", () => { - expect(appearanceFontStack("Fira Code", DEFAULT_CODE_FONT_STACK)).toBe( - `"Fira Code", ${DEFAULT_CODE_FONT_STACK}`, - ); - }); - - it("falls back to the default stack when unset", () => { - expect(appearanceFontStack("", DEFAULT_SANS_FONT_STACK)).toBe(DEFAULT_SANS_FONT_STACK); - }); -}); - -describe("resolveTerminalFontPreference", () => { - it("inherits the code font in simple mode", () => { - expect( - resolveTerminalFontPreference({ advanced: false, code: "Fira Code", terminal: "" }), - ).toBe("Fira Code"); - expect( - resolveTerminalFontPreference({ - advanced: false, - code: "Fira Code", - terminal: "Berkeley Mono", - }), - ).toBe("Fira Code"); - }); - - it("keeps code and terminal fonts independent in advanced mode", () => { - expect(resolveTerminalFontPreference({ advanced: true, code: "Fira Code", terminal: "" })).toBe( - "", - ); - expect( - resolveTerminalFontPreference({ - advanced: true, - code: "Fira Code", - terminal: "Berkeley Mono", - }), - ).toBe("Berkeley Mono"); - }); -}); - -describe("resolveTerminalFontSizePreference", () => { - it("inherits the code font size in simple mode", () => { - expect(resolveTerminalFontSizePreference({ advanced: false, code: 15, terminal: 12 })).toBe(15); - }); - - it("keeps code and terminal font sizes independent in advanced mode", () => { - expect(resolveTerminalFontSizePreference({ advanced: true, code: 15, terminal: 12 })).toBe(12); - }); -}); - -describe("font size clamping", () => { - it("keeps sizes inside the ranges the UI can absorb", () => { - expect(clampInterfaceFontSize(16)).toBe(16); - expect(clampInterfaceFontSize(2)).toBe(12); - expect(clampInterfaceFontSize(96)).toBe(20); - expect(clampPromptFontSize(40)).toBe(20); - expect(clampCodeFontSize(1)).toBe(10); - }); - - it("rounds fractional values and falls back for unusable input", () => { - expect(clampCodeFontSize(13.4)).toBe(13); - expect(clampInterfaceFontSize(Number.NaN)).toBe(16); - expect(clampPromptFontSize(Number.POSITIVE_INFINITY)).toBe(14); - }); -}); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 3341ec27c2c1..c816836ae8ae 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -46,7 +46,7 @@ function quoteFontFamilyName(name: string): string { return `"${bare.replaceAll('"', "")}"`; } -export function cssFontFamilies(input: string): string | null { +function cssFontFamilies(input: string): string | null { const families = input .split(",") .map(quoteFontFamilyName) @@ -110,7 +110,7 @@ function clampFontSize(value: number, minimum: number, maximum: number, fallback return Math.min(maximum, Math.max(minimum, Math.round(value))); } -export function clampInterfaceFontSize(value: number): number { +function clampInterfaceFontSize(value: number): number { return clampFontSize( value, MIN_INTERFACE_FONT_SIZE, @@ -119,11 +119,11 @@ export function clampInterfaceFontSize(value: number): number { ); } -export function clampPromptFontSize(value: number): number { +function clampPromptFontSize(value: number): number { return clampFontSize(value, MIN_PROMPT_FONT_SIZE, MAX_PROMPT_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE); } -export function clampCodeFontSize(value: number): number { +function clampCodeFontSize(value: number): number { return clampFontSize(value, MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE, DEFAULT_CODE_FONT_SIZE); } @@ -165,7 +165,7 @@ const MONOSPACE_PROBE_VARIANTS = ["normal 400", "normal 700", "italic 400", "ita const MONOSPACE_PROBE_GLYPHS = ["i", "M", "W", "0", "@", "#", ".", " "] as const; const MONOSPACE_ADVANCE_TOLERANCE = 0.01; -export function areFontAdvancesMonospace(advances: readonly number[]): boolean { +function areFontAdvancesMonospace(advances: readonly number[]): boolean { const reference = advances[0]; if ( reference === undefined || diff --git a/apps/web/src/assets/assetUrls.test.ts b/apps/web/src/assets/assetUrls.test.ts deleted file mode 100644 index 13ff3ea8e579..000000000000 --- a/apps/web/src/assets/assetUrls.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { - AuthFilesystemReadScope, - EnvironmentAuthorizationError, - EnvironmentId, - ThreadId, - type AuthSessionState, -} from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/reactivity"; -import { beforeEach, expect, it, vi } from "vite-plus/test"; - -const state = vi.hoisted(() => ({ - session: null as Pick | null, - phase: "connected" as "connected" | "offline", - assetAtom: {}, - mint: vi.fn(), - assetQuery: vi.fn(), -})); - -vi.mock("react", () => ({ useCallback: (callback: A) => callback })); -vi.mock("@effect/atom-react", () => ({ - useAtomValue: (atom: unknown) => - atom === state.assetAtom - ? AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }) - : AsyncResult.initial(false), -})); -vi.mock("~/state/session", () => ({ - usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }), -})); -vi.mock("~/state/filesystem", async () => { - const { resolveFilesystemReadAccess } = await import("@t3tools/client-runtime/state/filesystem"); - return { - useFilesystemReadAccess: () => - resolveFilesystemReadAccess({ - isCatalogReady: true, - connection: { phase: state.phase, error: null }, - session: state.session, - sessionError: null, - }), - }; -}); -vi.mock("~/state/assets", () => ({ - assetEnvironment: { createUrl: state.assetQuery }, -})); -vi.mock("~/state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint })); - -import { useAssetUrlRefresh, useAssetUrlState } from "./assetUrls"; - -const environmentId = EnvironmentId.make("asset-environment"); -const threadId = ThreadId.make("asset-thread"); -const resource = { _tag: "media-file", threadId, path: "/repo/image.png" } as const; - -beforeEach(() => { - state.session = null; - state.phase = "connected"; - state.assetQuery.mockReset().mockReturnValue(state.assetAtom); - state.mint - .mockReset() - .mockResolvedValue(AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 })); -}); - -it.each(["workspace-file", "media-file"] as const)( - "keeps %s loading until its file grant resolves", - (_tag) => { - expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({ _tag: "Loading" }); - expect(state.assetQuery).not.toHaveBeenCalled(); - - state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; - expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({ - _tag: "Success", - expiresAt: 1, - url: "https://host.test/api/assets/image.png", - }); - }, -); - -it("hides host assets with a denied grant while preserving attachments", () => { - state.session = { authenticated: true, scopes: [] }; - expect(useAssetUrlState(environmentId, resource)).toEqual({ - _tag: "Failure", - reason: "missing-file-access", - }); - expect(state.assetQuery).not.toHaveBeenCalled(); - expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({ - _tag: "Success", - expiresAt: 1, - url: "https://host.test/api/assets/image.png", - }); -}); - -it("stops waiting for an unresolved grant when the connection is offline", () => { - state.phase = "offline"; - expect(useAssetUrlState(environmentId, resource)).toEqual({ _tag: "Failure" }); - expect(state.assetQuery).not.toHaveBeenCalled(); -}); - -it("lets the server authorize an explicit refresh before the client grant loads", async () => { - await expect(useAssetUrlRefresh(environmentId, resource)()).resolves.toBe( - "https://host.test/api/assets/image.png", - ); - expect(state.mint).toHaveBeenCalledWith({ environmentId, input: { resource } }); - - const denied = new EnvironmentAuthorizationError({ - message: "This connection cannot read host files.", - requiredScope: AuthFilesystemReadScope, - }); - state.mint.mockResolvedValue(AsyncResult.failure(Cause.fail(denied))); - await expect(useAssetUrlRefresh(environmentId, resource)()).rejects.toBe(denied); -}); diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 6add5c1856e7..86823d66b567 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -8,11 +8,10 @@ import { import { createBrowserHistory } from "@tanstack/react-router"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { installEnvironmentHttpTest } from "../test/environmentHttpTest"; -import { __setPrimaryHttpRunnerForTests, type PrimaryHttpEffectRunner } from "./lib/runtime"; +import { __setPrimaryHttpRunnerForTests } from "./lib/runtime"; type TestWindow = { location: URL; @@ -77,21 +76,6 @@ function installTestBrowser(url: string) { return testWindow; } -function installDesktopBootstrap() { - const testWindow = installTestBrowser("http://localhost/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstraps: () => [ - { - id: "primary", - label: "Local environment", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - bootstrapToken: "desktop-bootstrap-token", - }, - ], - } as unknown as DesktopBridge; -} - function sequence(...values: ReadonlyArray) { let index = 0; return () => values[Math.min(index++, values.length - 1)]!; @@ -181,108 +165,6 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); }); - it("uses https urls when the primary environment uses wss", async () => { - await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) }); - vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); - vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); - - const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } = - await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: LOOPBACK_AUTH, - }); - expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe( - "https://remote.example.com/api/auth/session", - ); - }); - - it("uses the current origin as an auth proxy base for local dev environments", async () => { - await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) }); - installTestBrowser("http://localhost:5735/"); - - const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } = - await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: LOOPBACK_AUTH, - }); - expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe( - "http://localhost:5735/api/auth/session", - ); - }); - - it("uses the vite proxy for desktop-managed loopback auth requests during local dev", async () => { - await installAuthApi({ session: () => unauthenticatedSession(DESKTOP_AUTH) }); - vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); - - const testWindow = installTestBrowser("http://127.0.0.1:5733/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstraps: () => [ - { - id: "primary", - label: "Windows", - httpBaseUrl: "http://127.0.0.1:3773", - wsBaseUrl: "ws://127.0.0.1:3773", - }, - ], - } as unknown as DesktopBridge; - - const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } = - await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: DESKTOP_AUTH, - }); - expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe( - "http://127.0.0.1:5733/api/auth/session", - ); - }); - - it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => { - await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) }); - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: LOOPBACK_AUTH, - }); - }); - - it("retries transient auth session bootstrap failures after restart", async () => { - vi.useFakeTimers(); - let attempts = 0; - const request = HttpClientRequest.get("http://localhost/api/auth/session"); - const response = HttpClientResponse.fromWeb( - request, - new Response("Bad Gateway", { status: 502 }), - ); - const runner: PrimaryHttpEffectRunner = async () => { - attempts += 1; - if (attempts < 4) { - throw new HttpClientError.HttpClientError({ - reason: new HttpClientError.StatusCodeError({ request, response }), - }); - } - return unauthenticatedSession(LOOPBACK_AUTH) as A; - }; - __setPrimaryHttpRunnerForTests(runner); - - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - const gateStatePromise = resolveInitialServerAuthGateState(); - await vi.advanceTimersByTimeAsync(2_000); - - await expect(gateStatePromise).resolves.toEqual({ - status: "requires-auth", - auth: LOOPBACK_AUTH, - }); - expect(attempts).toBe(4); - }); - it("takes a pairing token from the location hash and strips it immediately", async () => { const testWindow = installTestBrowser("http://localhost/#token=pairing-token"); const { takePairingTokenFromUrl } = await import("./environments/primary"); @@ -292,14 +174,6 @@ describe("resolveInitialServerAuthGateState", () => { expect(testWindow.location.searchParams.get("token")).toBeNull(); }); - it("accepts query-string pairing tokens as a backward-compatible fallback", async () => { - const testWindow = installTestBrowser("http://localhost/?token=pairing-token"); - const { takePairingTokenFromUrl } = await import("./environments/primary"); - - expect(takePairingTokenFromUrl()).toBe("pairing-token"); - expect(testWindow.location.searchParams.get("token")).toBeNull(); - }); - it.each([ { suffix: "#token=replacement-token", cached: false }, { suffix: "#token=replacement-token", cached: true }, @@ -433,261 +307,6 @@ describe("resolveInitialServerAuthGateState", () => { }, ); - it("allows manual token submission after the initial auth check requires pairing", async () => { - const nextSession = sequence( - unauthenticatedSession(LOOPBACK_AUTH), - authenticatedSession(LOOPBACK_AUTH), - ); - const testApi = await installAuthApi({ - session: nextSession, - browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), - }); - const { resolveInitialServerAuthGateState, submitServerAuthCredential } = - await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: LOOPBACK_AUTH, - }); - await expect(submitServerAuthCredential("retry-token")).resolves.toBeUndefined(); - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "authenticated", - }); - expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); - expect(testApi.calls.session).toBe(2); - }); - - it("keeps manual token submission pending until the session is authenticated", async () => { - vi.useFakeTimers(); - let authenticated = false; - let settled = false; - try { - const testApi = await installAuthApi({ - session: () => - authenticated - ? authenticatedSession(LOOPBACK_AUTH) - : unauthenticatedSession(LOOPBACK_AUTH), - browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), - }); - const { submitServerAuthCredential } = await import("./environments/primary"); - - const submission = submitServerAuthCredential("retry-token").finally(() => { - settled = true; - }); - await vi.advanceTimersByTimeAsync(0); - - expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); - expect(testApi.calls.session).toBe(1); - expect(settled).toBe(false); - - authenticated = true; - await vi.advanceTimersByTimeAsync(100); - await expect(submission).resolves.toBeUndefined(); - expect(testApi.calls.session).toBe(2); - } finally { - vi.useRealTimers(); - } - }); - - it("fails manual token submission when the session is not established", async () => { - vi.useFakeTimers(); - try { - const testApi = await installAuthApi({ - session: () => unauthenticatedSession(LOOPBACK_AUTH), - browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), - }); - const { PrimaryEnvironmentAuthSessionTimeoutError, submitServerAuthCredential } = - await import("./environments/primary/auth"); - - const submission = submitServerAuthCredential("retry-token"); - const failure = submission.then( - () => null, - (error: unknown) => error, - ); - await vi.advanceTimersByTimeAsync(2_000); - - await expect(failure).resolves.toBeInstanceOf(PrimaryEnvironmentAuthSessionTimeoutError); - expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); - expect(testApi.calls.session).toBeGreaterThan(1); - } finally { - vi.useRealTimers(); - } - }); - - it("rejects a blank pairing token with a structured validation error", async () => { - const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } = - await import("./environments/primary/auth"); - - const error = await submitServerAuthCredential(" ").then( - () => null, - (failure: unknown) => failure, - ); - - expect(error).toBeInstanceOf(PrimaryEnvironmentPairingCredentialRequiredError); - expect(error).toMatchObject({ - _tag: "PrimaryEnvironmentPairingCredentialRequiredError", - providedLength: 3, - message: "Enter a pairing token to continue.", - }); - }); - - it("surfaces a friendly error message when an invalid pairing token is submitted", async () => { - const cause = new EnvironmentAuthInvalidError({ - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-invalid-credential", - }); - const testApi = await installAuthApi({ - browserSession: () => Effect.fail(cause), - }); - - const { isPrimaryEnvironmentPairingCredentialRejectedError, submitServerAuthCredential } = - await import("./environments/primary"); - - const error = await submitServerAuthCredential("bad-token").then( - () => null, - (failure: unknown) => failure, - ); - expect(error).toMatchObject({ - _tag: "PrimaryEnvironmentPairingCredentialRejectedError", - providedLength: 9, - message: "Invalid pairing token. Check the token and try again.", - }); - expect(isPrimaryEnvironmentPairingCredentialRejectedError(error)).toBe(true); - if (!isPrimaryEnvironmentPairingCredentialRejectedError(error)) { - throw new Error("Expected a structured rejected pairing credential error."); - } - expect(error.cause).toMatchObject({ - _tag: "EnvironmentAuthInvalidError", - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-invalid-credential", - }); - expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]); - }); - - it("derives primary request messages from structural request context", async () => { - const cause = new Error("private transport detail"); - const { PrimaryEnvironmentRequestError } = await import("./environments/primary"); - const error = PrimaryEnvironmentRequestError.fromCause({ - operation: "list-pairing-links", - cause, - }); - - expect(error.status).toBe(500); - expect(error.cause).toBe(cause); - expect(error.message).toBe( - "Primary environment request failed during list-pairing-links (HTTP 500).", - ); - expect(error.message).not.toContain(cause.message); - }); - - it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { - vi.useFakeTimers(); - const nextSession = sequence( - unauthenticatedSession(DESKTOP_AUTH), - unauthenticatedSession(DESKTOP_AUTH), - authenticatedSession(DESKTOP_AUTH), - ); - const testApi = await installAuthApi({ - session: nextSession, - browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), - }); - - const testWindow = installTestBrowser("http://localhost/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstraps: () => [ - { - id: "primary", - label: "Windows", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - bootstrapToken: "desktop-bootstrap-token", - }, - ], - } as unknown as DesktopBridge; - - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - const gateStatePromise = resolveInitialServerAuthGateState(); - await vi.advanceTimersByTimeAsync(100); - - await expect(gateStatePromise).resolves.toEqual({ status: "authenticated" }); - expect(testApi.calls.session).toBe(3); - }); - - it("preserves the timeout message when a bootstrapped session never becomes observable", async () => { - vi.useFakeTimers(); - const testApi = await installAuthApi({ - session: () => unauthenticatedSession(DESKTOP_AUTH), - browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), - }); - - installDesktopBootstrap(); - - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - const gateStatePromise = resolveInitialServerAuthGateState(); - await vi.advanceTimersByTimeAsync(2_000); - - await expect(gateStatePromise).resolves.toEqual({ - status: "requires-auth", - auth: DESKTOP_AUTH, - errorMessage: "Timed out waiting for authenticated session after bootstrap.", - }); - expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); - }); - - it("memoizes the authenticated gate state after the first successful read", async () => { - const testApi = await installAuthApi({ - session: sequence(authenticatedSession(LOOPBACK_AUTH), unauthenticatedSession(LOOPBACK_AUTH)), - }); - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "authenticated", - }); - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "authenticated", - }); - expect(testApi.calls.session).toBe(1); - }); - - it("exchanges a URL token when the browser already has a session", async () => { - const testApi = await installAuthApi({ - session: () => authenticatedSession(LOOPBACK_AUTH), - browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), - }); - const testWindow = installTestBrowser("http://localhost/#token=reusable-token"); - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "authenticated", - }); - - expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]); - expect(testWindow.location.hash).toBe(""); - }); - - it("exchanges a root URL token after caching an authenticated state", async () => { - const testApi = await installAuthApi({ - session: () => authenticatedSession(LOOPBACK_AUTH), - browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), - }); - const testWindow = installTestBrowser("http://localhost/"); - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "authenticated", - }); - testWindow.location = new URL("http://localhost/#token=reusable-token"); - - await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); - - expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]); - expect(testApi.calls.session).toBe(3); - }); - it("makes later callers wait for a URL token that arrives during bootstrap", async () => { let releaseExchange!: () => void; const exchangeRelease = new Promise((resolve) => { @@ -749,20 +368,6 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(2); }); - it("does not exchange a token during an ordinary authenticated load", async () => { - const testApi = await installAuthApi({ - session: () => authenticatedSession(LOOPBACK_AUTH), - browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), - }); - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "authenticated", - }); - - expect(testApi.calls.browserSession).toEqual([]); - }); - it("reports a rejected URL token without caching false success", async () => { const cause = new EnvironmentAuthInvalidError({ code: "auth_invalid", @@ -791,31 +396,4 @@ describe("resolveInitialServerAuthGateState", () => { }); expect(testApi.calls.browserSession).toEqual([{ credential: "rejected-token" }]); }); - - it("creates a pairing credential from the authenticated auth endpoint", async () => { - const testApi = await installAuthApi({ - pairingCredential: (payload) => - Effect.succeed({ - id: "pairing-link-1", - credential: "pairing-token", - ...(payload.label === undefined ? {} : { label: payload.label }), - expiresAt: SESSION_EXPIRES_AT, - }), - }); - const { createServerPairingCredential } = await import("./environments/primary"); - - const credential = await createServerPairingCredential({ - label: "Julius iPhone", - scopes: ["orchestration:read"], - }); - expect(credential).toMatchObject({ - id: "pairing-link-1", - credential: "pairing-token", - label: "Julius iPhone", - }); - expect(DateTime.formatIso(credential.expiresAt)).toBe("2026-04-05T00:00:00.000Z"); - expect(testApi.calls.pairingCredential).toEqual([ - { label: "Julius iPhone", scopes: ["orchestration:read"] }, - ]); - }); }); diff --git a/apps/web/src/bootstrap.test.ts b/apps/web/src/bootstrap.test.ts deleted file mode 100644 index c5c0d89597aa..000000000000 --- a/apps/web/src/bootstrap.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { showBootError } from "./lib/bootError"; - -class BootElement extends EventTarget { - children: BootElement[] = []; - textContent = ""; - - constructor(readonly tagName: string) { - super(); - } - - setAttribute() {} - - append(child: BootElement) { - this.children.push(child); - } - - replaceChildren(...children: BootElement[]) { - this.children = children; - } - - get text(): string { - return this.textContent + this.children.map((child) => child.text).join(" "); - } -} - -describe("app startup failures", () => { - let bootShell: BootElement | null; - - beforeEach(() => { - vi.resetModules(); - bootShell = new BootElement("div"); - vi.stubGlobal("document", { - getElementById: () => bootShell, - createElement: (tagName: string) => new BootElement(tagName), - }); - vi.spyOn(console, "error").mockImplementation(() => {}); - }); - - it("shows failures from asynchronous app startup", async () => { - vi.doMock("./main", () => ({ startup: Promise.reject(new Error("Startup chunks failed")) })); - - await import("./bootstrap"); - await vi.dynamicImportSettled(); - - expect(bootShell?.text).toContain("Startup chunks failed"); - }); - - afterEach(() => { - vi.doUnmock("./main"); - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); - vi.restoreAllMocks(); - }); - - it("replaces the splash when an app import throws before main can run", async () => { - vi.doMock("./main", () => { - throw new Error("@vitejs/plugin-react can't detect preamble. Something is wrong."); - }); - const reload = vi.fn(); - vi.stubGlobal("window", { location: { reload } }); - - await import("./bootstrap"); - await vi.dynamicImportSettled(); - - expect(bootShell?.text).toContain("T3 Code could not load."); - const reloadButton = bootShell?.children[0]?.children.find( - (element) => element.tagName === "button", - ); - expect(reloadButton?.text).toBe("Reload"); - reloadButton?.dispatchEvent(new Event("click")); - expect(reload).toHaveBeenCalledOnce(); - }); - - it.each([true, false])("shows startup error details only in dev mode, DEV=%s", (dev) => { - vi.stubEnv("DEV", dev); - - showBootError(new Error("internal module path")); - - expect(bootShell?.text).toContain("T3 Code could not load."); - expect(bootShell?.text.includes("internal module path")).toBe(dev); - }); - - it("does not replace the app after React removes the splash", () => { - bootShell = null; - const createElement = vi.spyOn(document, "createElement"); - - showBootError(new Error("late failure")); - - expect(createElement).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts deleted file mode 100644 index e1c87bcf0595..000000000000 --- a/apps/web/src/branding.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { - resolveServerBackedAppDisplayName, - resolveServerBackedAppStageLabel, -} from "./branding.logic"; - -const originalWindow = globalThis.window; - -afterEach(() => { - vi.resetModules(); - - if (originalWindow === undefined) { - Reflect.deleteProperty(globalThis, "window"); - return; - } - - globalThis.window = originalWindow; -}); - -describe("branding", () => { - it("uses injected desktop branding when available", async () => { - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - desktopBridge: { - getAppBranding: () => ({ - baseName: "T3 Code", - stageLabel: "Nightly", - displayName: "T3 Code (Nightly)", - }), - }, - }, - }); - - const branding = await import("./branding"); - - expect(branding.APP_BASE_NAME).toBe("T3 Code"); - expect(branding.APP_STAGE_LABEL).toBe("Nightly"); - expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)"); - }); - - it("normalizes hosted app channel metadata", async () => { - vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "nightly"); - - const branding = await import("./branding"); - - expect(branding.HOSTED_APP_CHANNEL).toBe("nightly"); - expect(branding.HOSTED_APP_CHANNEL_LABEL).toBe("Nightly"); - expect(branding.APP_STAGE_LABEL).toBe("Nightly"); - expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)"); - }); - - it("does not label the latest hosted app channel", async () => { - vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "latest"); - - const branding = await import("./branding"); - - expect(branding.HOSTED_APP_CHANNEL).toBe("latest"); - expect(branding.HOSTED_APP_CHANNEL_LABEL).toBe("Latest"); - expect(branding.APP_STAGE_LABEL).toBe("Latest"); - expect(branding.APP_DISPLAY_NAME).toBe("T3 Code"); - }); - - it("ignores unknown hosted app channels", async () => { - vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "preview"); - - const branding = await import("./branding"); - - expect(branding.HOSTED_APP_CHANNEL).toBeNull(); - expect(branding.HOSTED_APP_CHANNEL_LABEL).toBeNull(); - }); -}); - -describe("branding logic", () => { - it("returns Nightly for nightly primary server versions", () => { - expect( - resolveServerBackedAppStageLabel({ - primaryServerVersion: "0.0.28-nightly.20260616.12", - fallbackStageLabel: "Alpha", - }), - ).toBe("Nightly"); - }); - - it("updates the display name for nightly primary server versions", () => { - expect( - resolveServerBackedAppDisplayName({ - baseName: "T3 Code", - fallbackDisplayName: "T3 Code (Alpha)", - fallbackStageLabel: "Alpha", - primaryServerVersion: "0.0.28-nightly.20260616.12", - }), - ).toBe("T3 Code (Nightly)"); - }); - - it("keeps the fallback display name for stable primary server versions", () => { - expect( - resolveServerBackedAppDisplayName({ - baseName: "T3 Code", - fallbackDisplayName: "T3 Code (Alpha)", - fallbackStageLabel: "Alpha", - primaryServerVersion: "0.0.27", - }), - ).toBe("T3 Code (Alpha)"); - }); - - it("keeps the fallback display name for malformed nightly primary server versions", () => { - expect( - resolveServerBackedAppDisplayName({ - baseName: "T3 Code", - fallbackDisplayName: "T3 Code (Alpha)", - fallbackStageLabel: "Alpha", - primaryServerVersion: "0.0.28-nightly.20260616", - }), - ).toBe("T3 Code (Alpha)"); - }); -}); diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 528609d36e60..7d14e2cea858 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -25,3 +25,4 @@ export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? formatAppDisplayName({ baseName: APP_BASE_NAME, stageLabel: APP_STAGE_LABEL }); export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; +export const DESKTOP_DOWNLOAD_URL = "https://t3.codes/download"; diff --git a/apps/web/src/browser/BrowserDeviceToolbar.test.ts b/apps/web/src/browser/BrowserDeviceToolbar.test.ts deleted file mode 100644 index 087b5d109b40..000000000000 --- a/apps/web/src/browser/BrowserDeviceToolbar.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { PreviewViewportSetting } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState"; - -describe("commitViewportAndAspectRatio", () => { - it("commits the aspect ratio only after the viewport succeeds", async () => { - let resolveChange: (() => void) | undefined; - const onChange = vi.fn( - () => - new Promise((resolve) => { - resolveChange = resolve; - }), - ); - const onAspectRatioChange = vi.fn(); - const setting: PreviewViewportSetting = { _tag: "freeform", width: 900, height: 600 }; - - const commit = commitViewportAndAspectRatio(setting, 1.5, onChange, onAspectRatioChange); - expect(onAspectRatioChange).not.toHaveBeenCalled(); - - resolveChange?.(); - await commit; - expect(onAspectRatioChange).toHaveBeenCalledWith(1.5); - }); - - it("keeps the previous aspect ratio when the viewport commit fails", async () => { - const onAspectRatioChange = vi.fn(); - await expect( - commitViewportAndAspectRatio( - { _tag: "fill" }, - null, - async () => Promise.reject(new Error("resize failed")), - onAspectRatioChange, - ), - ).rejects.toThrow("resize failed"); - expect(onAspectRatioChange).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index 390c77de4424..fbd843c8d43f 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -1,5 +1,3 @@ -"use client"; - import { PREVIEW_VIEWPORT_MAX_AREA, PREVIEW_VIEWPORT_MAX_DIMENSION, diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index 26aa15c08fae..0128a203b208 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useLayoutEffect, useRef } from "react"; import { acquireBrowserSurface } from "./browserSurfaceStore"; diff --git a/apps/web/src/browser/BrowserViewportResizeHandles.tsx b/apps/web/src/browser/BrowserViewportResizeHandles.tsx index 08e69c359a15..1c621e0070fb 100644 --- a/apps/web/src/browser/BrowserViewportResizeHandles.tsx +++ b/apps/web/src/browser/BrowserViewportResizeHandles.tsx @@ -1,5 +1,3 @@ -"use client"; - import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, diff --git a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx index 5d1e02658d58..29c217e064ad 100644 --- a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx @@ -7,9 +7,9 @@ import { type DesktopPreviewBridge, type PreviewSessionSnapshot, } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, fakeTimers, render } from "../../test/render"; const mocks = vi.hoisted(() => ({ getClientSettings: vi.fn<() => Promise>(), @@ -43,7 +43,7 @@ vi.mock(import("~/components/preview/previewBridge"), async (original) => ({ closeTab: mocks.closeTab, registerWebview: mocks.registerWebview, getPreviewConfig: mocks.getPreviewConfig, - } as unknown as DesktopPreviewBridge, + } as never, })); vi.mock(import("~/components/preview/usePreviewBridge"), async (original) => ({ @@ -73,10 +73,7 @@ const snapshot = (tabId: string, updatedAt: string): PreviewSessionSnapshot => ( updatedAt, }); -let root: Root | undefined; -let container: HTMLDivElement; - -beforeEach(() => { +function setup(): Disposable { __resetClientSettingsPersistenceForTests(); mocks.getClientSettings.mockReset().mockResolvedValue(DEFAULT_CLIENT_SETTINGS); mocks.setClientSettings.mockReset().mockResolvedValue(undefined); @@ -88,26 +85,37 @@ beforeEach(() => { webPreferences: "contextIsolation=yes", preloadUrl: null, }); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); -}); - -afterEach(async () => { - vi.useFakeTimers(); - await act(async () => root?.unmount()); - root = undefined; - container.remove(); - await vi.advanceTimersByTimeAsync(0); - vi.useRealTimers(); - resetPreviewStateForTests(); - __resetClientSettingsPersistenceForTests(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + return { + [Symbol.dispose]() { + resetPreviewStateForTests(); + __resetClientSettingsPersistenceForTests(); + }, + }; +} + +async function renderHost() { + await using stack = new AsyncDisposableStack(); + const screen = stack.use( + await render( + + + , + ), + ); + // Unmounting releases each tab on a close timer; run it now so it never fires in a later test. + stack.defer(async () => { + using _timers = fakeTimers(); + await screen.unmount(); + await vi.advanceTimersByTimeAsync(0); + }); + await act(() => ensureClientSettingsHydrated()); + const environment = stack.move(); + return { container: screen.container, [Symbol.asyncDispose]: () => environment.disposeAsync() }; +} describe("ElectronBrowserHost", () => { it("never moves a tab's webview when the server reorders tabs by last update", async () => { + using _state = setup(); reconcilePreviewServerSessions(threadRef, { sessions: [ snapshot("tab_7", "2026-01-01T00:00:01.000Z"), @@ -116,17 +124,9 @@ describe("ElectronBrowserHost", () => { serverEpoch: "server-a", revision: 1, }); - root = createRoot(container); - await act(async () => { - root!.render( - - - , - ); - await ensureClientSettingsHydrated(); - }); + await using screen = await renderHost(); - const host = container.querySelector("[data-electron-browser-host]")!; + const host = screen.container.querySelector("[data-electron-browser-host]")!; const webviews = () => [...host.querySelectorAll("webview")]; const before = webviews(); expect(before.map((node) => node.getAttribute("data-preview-server-tab")).toSorted()).toEqual([ diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 02e45bb950be..8818b377f5e3 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -1,5 +1,3 @@ -"use client"; - import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { AuthPreviewOperateScope, @@ -13,6 +11,7 @@ import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { isElectron } from "~/env"; import { useClientSettingsHydrated } from "~/hooks/useSettings"; +import { useDesktopHostedPreviewSessions } from "~/components/preview/usePreviewSession"; import { useActivePreviewSessions } from "~/previewStateStore"; import { previewEnvironment } from "~/state/preview"; import { useEnvironmentScope } from "~/state/session"; @@ -29,6 +28,7 @@ import { previewRuntimeTabId } from "./previewRuntimeTabId"; export function ElectronBrowserHost() { const previewByThreadKey = useActivePreviewSessions(); const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); + useDesktopHostedPreviewSessions(primaryEnvironmentId); const activeSessions = Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { const threadRef = parseScopedThreadKey(threadKey); return threadRef diff --git a/apps/web/src/browser/HostedBrowserWebview.browser.test.tsx b/apps/web/src/browser/HostedBrowserWebview.browser.test.tsx deleted file mode 100644 index b534fae0215e..000000000000 --- a/apps/web/src/browser/HostedBrowserWebview.browser.test.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { - DEFAULT_CLIENT_SETTINGS, - EnvironmentId, - FILL_PREVIEW_VIEWPORT, - ThreadId, - type ClientSettings, - type DesktopPreviewBridge, -} from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const mocks = vi.hoisted(() => ({ - getClientSettings: vi.fn<() => Promise>(), - setClientSettings: vi.fn<(settings: ClientSettings) => Promise>(), - createTab: vi.fn(), - closeTab: vi.fn(), - registerWebview: vi.fn(), - getPreviewConfig: vi.fn(), - activeRecordings: new Set(), -})); - -vi.mock(import("~/localApi"), async (original) => { - const actual = await original(); - return { - ...actual, - ensureLocalApi: () => ({ ...actual.ensureLocalApi(), persistence: mocks as never }), - }; -}); - -vi.mock(import("./desktopTabLifetime"), { spy: true }); - -// The Electron desktop bridge; a browser has none. -vi.mock(import("~/components/preview/previewBridge"), async (original) => ({ - ...(await original()), - previewBridge: { - createTab: mocks.createTab, - closeTab: mocks.closeTab, - registerWebview: mocks.registerWebview, - getPreviewConfig: mocks.getPreviewConfig, - } as unknown as DesktopPreviewBridge, -})); - -vi.mock(import("~/components/preview/usePreviewBridge"), async (original) => ({ - ...(await original()), - usePreviewBridge: () => undefined, -})); - -import { - __resetClientSettingsPersistenceForTests, - ensureClientSettingsHydrated, -} from "~/hooks/useSettings"; -import { useBrowserSurfaceStore } from "./browserSurfaceStore"; -import * as desktopTabLifetime from "./desktopTabLifetime"; -import { HostedBrowserWebview } from "./HostedBrowserWebview"; - -let root: Root | undefined; -let container: HTMLDivElement; - -function deferred() { - let resolve!: (value: A) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -beforeEach(() => { - __resetClientSettingsPersistenceForTests(); - useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); - mocks.getClientSettings.mockReset(); - mocks.setClientSettings.mockReset().mockResolvedValue(undefined); - mocks.createTab.mockReset().mockResolvedValue(undefined); - mocks.closeTab.mockReset().mockResolvedValue(undefined); - mocks.registerWebview.mockReset().mockResolvedValue(undefined); - mocks.getPreviewConfig.mockReset().mockResolvedValue({ - partition: "persist:t3-preview-work", - webPreferences: "contextIsolation=yes", - preloadUrl: null, - }); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.spyOn(console, "error").mockImplementation(() => undefined); - container = document.createElement("div"); - document.body.append(container); -}); - -afterEach(async () => { - vi.useFakeTimers(); - await act(async () => root?.unmount()); - root = undefined; - container.remove(); - await vi.advanceTimersByTimeAsync(0); - vi.useRealTimers(); - __resetClientSettingsPersistenceForTests(); - useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -describe("HostedBrowserWebview settings hydration", () => { - it("starts a retained background tab only after a settings read succeeds on retry", async () => { - const firstRead = deferred(); - const retryRead = deferred(); - const tabCreation = deferred(); - mocks.getClientSettings - .mockReturnValueOnce(firstRead.promise) - .mockReturnValueOnce(retryRead.promise); - mocks.createTab.mockReturnValueOnce(tabCreation.promise); - const acquire = vi.mocked(desktopTabLifetime.acquireDesktopTab); - // Chromium treats as an unknown element; give it the one Electron method the - // host calls once the guest attaches. - const createElement = document.createElement.bind(document); - const createGuest = vi.spyOn(document, "createElement").mockImplementation((( - tagName: string, - options?: ElementCreationOptions, - ) => { - const element = createElement(tagName, options); - return tagName === "webview" - ? Object.assign(element, { getWebContentsId: () => 41 }) - : element; - }) as typeof document.createElement); - const guests = () => createGuest.mock.calls.filter(([tagName]) => tagName === "webview"); - const guest = () => container.querySelector("webview"); - const threadRef = { - environmentId: EnvironmentId.make("host-settings-retry"), - threadId: ThreadId.make("thread-settings-retry"), - }; - const runtimeTabId = "retained-background-tab"; - useBrowserSurfaceStore.getState().acquireActivity(runtimeTabId); - - root = createRoot(container); - await act(async () => - root!.render( - , - ), - ); - - expect(mocks.getClientSettings).toHaveBeenCalledOnce(); - expect(acquire).not.toHaveBeenCalled(); - expect(guest()).toBeNull(); - expect(mocks.createTab).not.toHaveBeenCalled(); - - const failure = new Error("Saved settings are unavailable"); - await act(async () => { - const hydration = ensureClientSettingsHydrated(); - firstRead.reject(failure); - await expect(hydration).rejects.toBe(failure); - }); - expect(acquire).not.toHaveBeenCalled(); - expect(guest()).toBeNull(); - expect(mocks.createTab).not.toHaveBeenCalled(); - - let retry!: Promise; - await act(() => { - retry = ensureClientSettingsHydrated(); - }); - expect(mocks.getClientSettings).toHaveBeenCalledTimes(2); - expect(acquire).not.toHaveBeenCalled(); - expect(guest()).toBeNull(); - expect(mocks.createTab).not.toHaveBeenCalled(); - - await act(async () => { - retryRead.resolve({ - ...DEFAULT_CLIENT_SETTINGS, - browserDefaultZoomFactor: 1.25, - browserDefaultAppearance: "dark", - browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], - browserDefaultProfileId: "work", - }); - await retry; - }); - - expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId, undefined); - expect(mocks.getPreviewConfig).toHaveBeenCalledExactlyOnceWith(threadRef.environmentId, "work"); - expect(guests()).toHaveLength(1); - expect(guest()?.getAttribute("partition")).toBe("persist:t3-preview-work"); - expect(guest()?.getAttribute("src")).toBe("https://example.com"); - expect(mocks.createTab).toHaveBeenCalledExactlyOnceWith(runtimeTabId, { - zoomFactor: 1.25, - colorScheme: "dark", - }); - expect(mocks.registerWebview).not.toHaveBeenCalled(); - - await act(async () => { - tabCreation.resolve(); - await tabCreation.promise; - }); - expect(mocks.registerWebview).toHaveBeenCalledExactlyOnceWith(runtimeTabId, 41); - expect(mocks.closeTab).not.toHaveBeenCalled(); - expect(mocks.setClientSettings).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 616ed7969f11..4dc393ffa4d8 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -1,5 +1,3 @@ -"use client"; - import type { DesktopPreviewColorScheme, PreviewViewportSetting, @@ -25,6 +23,7 @@ import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; import { acquireDesktopTab, type AcquiredDesktopTab, withDesktopTab } from "./desktopTabLifetime"; import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle"; import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; +import { useBrowserLayoutZoomFactor } from "./useBrowserLayoutZoomFactor"; import { useBrowserViewportResize } from "./useBrowserViewportResize"; import { INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, @@ -209,7 +208,7 @@ export function HostedBrowserWebview(props: { const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; - const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; + const layoutZoomFactor = useBrowserLayoutZoomFactor(zoomFactor); const viewportWidth = viewport._tag === "fill" ? null : viewport.width; const viewportHeight = viewport._tag === "fill" ? null : viewport.height; const viewportAspectRatio = @@ -222,13 +221,13 @@ export function HostedBrowserWebview(props: { const content = presentation.content; const hiddenWidth = viewport._tag !== "fill" - ? viewport.width * normalizedZoomFactor + ? viewport.width * layoutZoomFactor : content ? content.width / content.scale : (lastRect?.width ?? 1280); const hiddenHeight = viewport._tag !== "fill" - ? viewport.height * normalizedZoomFactor + ? viewport.height * layoutZoomFactor : content ? content.height / content.scale : (lastRect?.height ?? 800); @@ -247,22 +246,18 @@ export function HostedBrowserWebview(props: { } = useBrowserViewportResize({ tabId: runtimeTabId, viewport, - zoomFactor, + zoomFactor: layoutZoomFactor, containerSize, deviceToolbarVisible, aspectRatio: lockedAspectRatio, }); const fittedSourceViewport = presentation.fitSourceContent && lastRect - ? resolveFittedBrowserViewport( - viewport, - presentation.fittedSourceContent, - normalizedZoomFactor, - ) + ? resolveFittedBrowserViewport(viewport, presentation.fittedSourceContent, layoutZoomFactor) : null; const layout = fittedSourceViewport && lastRect - ? resolveBrowserViewportLayout(lastRect, fittedSourceViewport, normalizedZoomFactor) + ? resolveBrowserViewportLayout(lastRect, fittedSourceViewport, layoutZoomFactor) : viewportLayout; const syncContentPresentation = () => { @@ -350,14 +345,14 @@ export function HostedBrowserWebview(props: { fittedSourceViewport ? fittedSourceViewport.width : effectiveViewport._tag === "fill" - ? Math.max(1, Math.round(layout.viewportWidth / normalizedZoomFactor)) + ? Math.max(1, Math.round(layout.viewportWidth / layoutZoomFactor)) : effectiveViewport.width } data-preview-css-height={ fittedSourceViewport ? fittedSourceViewport.height : effectiveViewport._tag === "fill" - ? Math.max(1, Math.round(layout.viewportHeight / normalizedZoomFactor)) + ? Math.max(1, Math.round(layout.viewportHeight / layoutZoomFactor)) : effectiveViewport.height } aria-hidden={active ? undefined : true} diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 27ae15e5e798..c0de4f0d32ea 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -1,5 +1,3 @@ -"use client"; - import { createPreviewFramePainter, createPreviewStreamClient, diff --git a/apps/web/src/browser/browserDefaults.test.ts b/apps/web/src/browser/browserDefaults.test.ts index 5384d093b883..df371a83b317 100644 --- a/apps/web/src/browser/browserDefaults.test.ts +++ b/apps/web/src/browser/browserDefaults.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; -import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; - const settings = vi.hoisted(() => ({ current: {} as Record })); vi.mock("~/hooks/useSettings", () => ({ @@ -11,7 +9,7 @@ vi.mock("~/hooks/useSettings", () => ({ ensureClientSettingsHydrated: vi.fn(async () => undefined), })); -const { getBrowserDefaults, resolveBrowserDefaults } = await import("./browserDefaults"); +const { getBrowserDefaults } = await import("./browserDefaults"); const withDefaultProfile = (browserDefaultProfileId: string) => { settings.current = { @@ -26,36 +24,9 @@ const withDefaultProfile = (browserDefaultProfileId: string) => { }; describe("getBrowserDefaults profile resolution", () => { - it("keeps a configured persistent profile", () => { - expect(withDefaultProfile("work").profileId).toBe("work"); - }); - - it("falls back for an unknown profile", () => { - expect(withDefaultProfile("deleted").profileId).toBe(DEFAULT_BROWSER_PROFILE_ID); - }); - it("refuses incognito as the default", () => { expect(withDefaultProfile(INCOGNITO_BROWSER_PROFILE_ID).profileId).toBe( DEFAULT_BROWSER_PROFILE_ID, ); }); }); - -describe("resolveBrowserDefaults", () => { - it("rejects failed reads and uses the saved profile after a successful retry", async () => { - withDefaultProfile("work"); - settings.current.browserDefaultZoomFactor = 1.25; - settings.current.browserDefaultAppearance = "dark"; - const failure = new Error("Settings read failed"); - vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); - - await expect(resolveBrowserDefaults()).rejects.toBe(failure); - await expect(resolveBrowserDefaults()).resolves.toMatchObject({ - viewport: { _tag: "fill" }, - zoomFactor: 1.25, - appearance: "dark", - autoShowFloatingPreview: true, - profileId: "work", - }); - }); -}); diff --git a/apps/web/src/browser/browserLinkTarget.test.ts b/apps/web/src/browser/browserLinkTarget.test.ts index 7747898d0818..55bf7c3be8ee 100644 --- a/apps/web/src/browser/browserLinkTarget.test.ts +++ b/apps/web/src/browser/browserLinkTarget.test.ts @@ -1,9 +1,7 @@ import type { BrowserLinkTarget } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; - -import { resolveBrowserLinkTargetPreference, resolveLinkTarget } from "./browserLinkTarget"; +import { resolveLinkTarget } from "./browserLinkTarget"; const settings = vi.hoisted(() => ({ browserLinkTarget: "system" as BrowserLinkTarget })); @@ -15,66 +13,6 @@ vi.mock("~/hooks/useSettings", () => ({ const click = { metaKey: false, ctrlKey: false }; describe("resolveLinkTarget", () => { - it("keeps the system browser unless the user asked for in-app", () => { - expect( - resolveLinkTarget({ - url: "https://example.com/", - event: click, - preference: "system", - canOpenInApp: true, - }), - ).toBe("system"); - }); - - it("opens in-app when asked and the runtime can", () => { - expect( - resolveLinkTarget({ - url: "https://example.com/", - event: click, - preference: "app", - canOpenInApp: true, - }), - ).toBe("app"); - }); - - it("falls back to the system browser where there is no in-app browser", () => { - expect( - resolveLinkTarget({ - url: "https://example.com/", - event: click, - preference: "app", - canOpenInApp: false, - }), - ).toBe("system"); - }); - - it("treats a modifier click as the way out of the in-app default", () => { - expect( - resolveLinkTarget({ - url: "https://example.com/", - event: { metaKey: true, ctrlKey: false }, - preference: "app", - canOpenInApp: true, - }), - ).toBe("system"); - expect( - resolveLinkTarget({ - url: "https://example.com/", - event: { metaKey: false, ctrlKey: true }, - preference: "app", - canOpenInApp: true, - }), - ).toBe("system"); - expect( - resolveLinkTarget({ - url: "https://example.com/", - event: { metaKey: false, ctrlKey: false, button: 1 }, - preference: "app", - canOpenInApp: true, - }), - ).toBe("system"); - }); - it("leaves non-web schemes to the shell", () => { for (const url of ["mailto:someone@example.com", "vscode://file/x", "not a url"]) { expect(resolveLinkTarget({ url, event: click, preference: "app", canOpenInApp: true })).toBe( @@ -83,17 +21,3 @@ describe("resolveLinkTarget", () => { } }); }); - -describe("resolveBrowserLinkTargetPreference", () => { - it.each(["system", "app"] as const)( - "rejects failed reads instead of using the current %s preference", - async (preference) => { - settings.browserLinkTarget = preference; - const failure = new Error("Settings read failed"); - vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); - - await expect(resolveBrowserLinkTargetPreference()).rejects.toBe(failure); - await expect(resolveBrowserLinkTargetPreference()).resolves.toBe(preference); - }, - ); -}); diff --git a/apps/web/src/browser/browserPointerStore.test.ts b/apps/web/src/browser/browserPointerStore.test.ts deleted file mode 100644 index de9c173dc2da..000000000000 --- a/apps/web/src/browser/browserPointerStore.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { beforeEach, describe, expect, it } from "vite-plus/test"; - -import { useBrowserPointerStore } from "./browserPointerStore"; - -beforeEach(() => { - useBrowserPointerStore.setState({ byTabId: {} }); -}); - -describe("browserPointerStore", () => { - it("tracks the latest pointer target independently for each tab", () => { - const store = useBrowserPointerStore.getState(); - store.apply({ - tabId: "tab_a", - phase: "move", - x: 20, - y: 30, - sequence: 0, - createdAt: "2026-06-12T00:00:00.000Z", - }); - store.apply({ - tabId: "tab_b", - phase: "move", - x: 40, - y: 50, - sequence: 1, - createdAt: "2026-06-12T00:00:01.000Z", - }); - store.apply({ - tabId: "tab_a", - phase: "click", - x: 60, - y: 70, - sequence: 2, - createdAt: "2026-06-12T00:00:02.000Z", - }); - - expect(useBrowserPointerStore.getState().byTabId).toMatchObject({ - tab_a: { phase: "click", x: 60, y: 70, sequence: 2 }, - tab_b: { phase: "move", x: 40, y: 50, sequence: 1 }, - }); - }); - - it("clears one tab without affecting the others", () => { - const store = useBrowserPointerStore.getState(); - store.apply({ - tabId: "tab_a", - phase: "move", - x: 20, - y: 30, - sequence: 0, - createdAt: "2026-06-12T00:00:00.000Z", - }); - store.apply({ - tabId: "tab_b", - phase: "move", - x: 40, - y: 50, - sequence: 1, - createdAt: "2026-06-12T00:00:01.000Z", - }); - - store.clear("tab_a"); - - expect(useBrowserPointerStore.getState().byTabId).toEqual({ - tab_b: expect.objectContaining({ x: 40, y: 50 }), - }); - }); -}); diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts index bd38c4f9f69a..67b66ba4bade 100644 --- a/apps/web/src/browser/browserRecording.test.ts +++ b/apps/web/src/browser/browserRecording.test.ts @@ -2,12 +2,12 @@ import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, EnvironmentId, ThreadId, + type DesktopPreviewBridge, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; - const { + captureScreenshot, clientSettings, events, getDisplayMedia, @@ -19,6 +19,7 @@ const { } = vi.hoisted(() => { const events: string[] = []; return { + captureScreenshot: vi.fn(), clientSettings: { browserRecordingFrameRate: 30 as 30 | 60 }, events, getDisplayMedia: vi.fn(), @@ -36,26 +37,37 @@ const { sizeBytes: 0, createdAt: "2026-06-26T00:00:00.000Z", })), - startScreencast: vi.fn(async (_tabId: string) => { + startScreencast: vi.fn(async (_tabId) => { events.push("start-screencast"); }), stopScreencast: vi.fn(async () => undefined), }; }); -vi.mock("~/components/preview/previewBridge", () => ({ - previewBridge: { - recording: { - onFrame: vi.fn(), - save, - startScreencast: async (tabId: string) => { - await startScreencast(tabId); - requestDisplayMediaCapture(tabId); +vi.mock(import("~/components/preview/previewBridge"), async (original) => { + vi.stubGlobal("window", { + desktopBridge: { + preview: { + captureScreenshot, + recording: { + onFrame: vi.fn(), + save, + startScreencast: async (tabId: string) => { + const result = await startScreencast(tabId); + if (result === undefined) requestDisplayMediaCapture(tabId); + return result; + }, + stopScreencast, + }, }, - stopScreencast, }, - }, -})); + }); + try { + return await original(); + } finally { + vi.unstubAllGlobals(); + } +}); vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: { set: registrySet }, @@ -67,12 +79,7 @@ vi.mock("~/hooks/useSettings", () => ({ })); import { - BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS, - BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, - BrowserRecordingCaptureTimeoutError, BrowserRecordingConflictError, - BrowserRecordingFormatUnavailableError, - BrowserRecordingStartCancelledError, findActiveBrowserRecordingRuntimeTabId, readActiveBrowserRecordingTabIds, readActiveBrowserRecordingTargets, @@ -165,22 +172,6 @@ describe("browser recording", () => { vi.unstubAllGlobals(); }); - it("starts recording for a visible tab", async () => { - await startBrowserRecording("recording-tab"); - const startupEvents = [...events]; - - await stopBrowserRecording("recording-tab"); - expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]); - }); - - it("routes gesture-free starts through the desktop capture trigger", async () => { - await startBrowserRecording("automation-recording-tab"); - - expect(requestDisplayMediaCapture).toHaveBeenCalledWith("automation-recording-tab"); - expect(getDisplayMedia).toHaveBeenCalledOnce(); - await stopBrowserRecording("automation-recording-tab"); - }); - it("saves locally and releases capture before transferring the encoded recording once", async () => { const stopTrack = vi.fn(); getDisplayMedia.mockResolvedValue({ @@ -223,109 +214,6 @@ describe("browser recording", () => { await stopBrowserRecording("failed-transfer-tab"); }); - it("paints and holds a hidden browser surface for the recording lifetime", async () => { - startScreencast.mockImplementationOnce(async (tabId: string) => { - expect(animationFrameCount).toBe(2); - expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBe(1); - }); - getDisplayMedia.mockImplementationOnce(async () => { - expect(animationFrameCount).toBe(2); - expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1); - return { getVideoTracks: () => [], getTracks: () => [{ stop: vi.fn() }] }; - }); - - await startBrowserRecording("background-tab"); - expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1); - - await stopBrowserRecording("background-tab"); - expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBeUndefined(); - }); - - it("bounds compositor warmup when animation frames are paused", async () => { - vi.useFakeTimers(); - const cancelAnimationFrame = vi.fn(); - vi.stubGlobal( - "requestAnimationFrame", - vi.fn(() => 42), - ); - vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame); - - const startPromise = startBrowserRecording("hidden-window-tab"); - await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS); - - await startPromise; - expect(cancelAnimationFrame).toHaveBeenCalledWith(42); - await stopBrowserRecording("hidden-window-tab"); - }); - - it.each([ - { width: 1280, height: 720, frameRate: 60, bitrate: 2_764_800 }, - { width: 320, height: 240, frameRate: 30, bitrate: 2_500_000 }, - { width: 3840, height: 2160, frameRate: 60, bitrate: 24_883_200 }, - { width: 7680, height: 4320, frameRate: 60, bitrate: 50_000_000 }, - ])("records the native $width x $height stream at $frameRate fps", async (settings) => { - const stopTrack = vi.fn(); - const stream = { - getVideoTracks: () => [{ getSettings: () => settings }], - getTracks: () => [{ stop: stopTrack }], - } as unknown as MediaStream; - getDisplayMedia.mockResolvedValueOnce(stream); - - await startBrowserRecording("recording-tab"); - - expect(getDisplayMedia).toHaveBeenCalledWith({ - audio: false, - video: { frameRate: { ideal: 30, max: 30 } }, - }); - expect(FakeMediaRecorder.instances[0]?.stream).toBe(stream); - expect(FakeMediaRecorder.instances[0]?.options?.videoBitsPerSecond).toBe(settings.bitrate); - - await stopBrowserRecording("recording-tab"); - expect(stopTrack).toHaveBeenCalledOnce(); - expect(stopTrack.mock.invocationCallOrder[0]).toBeLessThan(save.mock.invocationCallOrder[0]!); - }); - - it("uses the configured recording frame rate", async () => { - clientSettings.browserRecordingFrameRate = 60; - - await startBrowserRecording("recording-tab"); - - expect(getDisplayMedia).toHaveBeenCalledWith({ - audio: false, - video: { frameRate: { ideal: 60, max: 60 } }, - }); - await stopBrowserRecording("recording-tab"); - }); - - it("clears a failed settings read before retrying recording", async () => { - const tabId = "settings-read-failure-tab"; - const error = new Error("Settings read failed"); - vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(error); - - await expect(startBrowserRecording(tabId)).rejects.toBe(error); - - expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); - expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); - expect(animationFrameCount).toBe(0); - expect(startScreencast).not.toHaveBeenCalled(); - expect(stopScreencast).not.toHaveBeenCalled(); - expect(getDisplayMedia).not.toHaveBeenCalled(); - expect(FakeMediaRecorder.instances).toHaveLength(0); - - clientSettings.browserRecordingFrameRate = 60; - await startBrowserRecording(tabId); - - expect(getDisplayMedia).toHaveBeenCalledWith({ - audio: false, - video: { frameRate: { ideal: 60, max: 60 } }, - }); - await stopBrowserRecording(tabId); - - expect(startScreencast).toHaveBeenCalledOnce(); - expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); - expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); - }); - it("stops the native stream when MediaRecorder cleanup fails", async () => { const stopTrack = vi.fn(); getDisplayMedia.mockResolvedValueOnce({ @@ -334,112 +222,33 @@ describe("browser recording", () => { }); await startBrowserRecording("recording-tab"); - FakeMediaRecorder.stopError = new Error("stop failed"); + const cleanupCause = new DOMException("Could not start video source", "NotReadableError"); + FakeMediaRecorder.stopError = cleanupCause; await expect(stopBrowserRecording("recording-tab")).rejects.toMatchObject({ operation: "cleanup", tabId: "recording-tab", + message: "Browser recording operation cleanup failed for tab recording-tab.", }); expect(stopTrack).toHaveBeenCalledOnce(); }); - it("uses the best supported encoder and saves the recorder's actual format", async () => { - FakeMediaRecorder.supportedTypes = new Set([ - "video/mp4;codecs=avc1", - "video/mp4;codecs=avc1.42e01e", - "video/webm;codecs=vp9", - "video/webm;codecs=av1", - ]); - FakeMediaRecorder.outputMimeType = "video/webm;codecs=av01"; - - await startBrowserRecording("recording-tab"); - await stopBrowserRecording("recording-tab"); - - expect(FakeMediaRecorder.instances[0]?.options).toEqual({ - mimeType: "video/mp4;codecs=avc1", - videoBitsPerSecond: 3_110_400, - }); - expect(save).toHaveBeenCalledWith( - "recording-tab", - "video/webm;codecs=av01", - expect.any(Uint8Array), - ); - }); - - it("lets the browser select the format when no preferred encoding is supported", async () => { - FakeMediaRecorder.supportedTypes = new Set(); - FakeMediaRecorder.outputMimeType = "video/platform-default"; - - await startBrowserRecording("recording-tab"); - await stopBrowserRecording("recording-tab"); - - expect(FakeMediaRecorder.instances[0]?.options).toEqual({ videoBitsPerSecond: 3_110_400 }); - expect(save).toHaveBeenCalledWith( - "recording-tab", - "video/platform-default", - expect.any(Uint8Array), - ); - }); - - it("reports when MediaRecorder provides no output format", async () => { - FakeMediaRecorder.supportedTypes = new Set(); - FakeMediaRecorder.outputMimeType = ""; - - await startBrowserRecording("recording-tab"); - - await expect(stopBrowserRecording("recording-tab")).rejects.toBeInstanceOf( - BrowserRecordingFormatUnavailableError, - ); - expect(save).not.toHaveBeenCalled(); - expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); - }); - it("releases the native capture lease when stream acquisition fails", async () => { - getDisplayMedia.mockRejectedValueOnce(new Error("capture failed")); + const cause = new Error("capture failed"); + cause.name = "NotReadableError"; + getDisplayMedia.mockRejectedValueOnce(cause); await expect(startBrowserRecording("recording-tab")).rejects.toMatchObject({ operation: "capture-media-stream", tabId: "recording-tab", + cause, + message: "Browser recording operation capture-media-stream failed for tab recording-tab.", }); expect(stopScreencast).toHaveBeenCalledWith("recording-tab"); expect(events.at(-1)).toBe("clear"); }); - it("times out stalled stream acquisition and stops a late stream", async () => { - vi.useFakeTimers(); - let finishCapture!: (stream: MediaStream) => void; - const stopTrack = vi.fn(); - getDisplayMedia.mockImplementationOnce( - () => - new Promise((resolve) => { - finishCapture = resolve; - }), - ); - - const startPromise = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); - const rejection = expect(startPromise).rejects.toMatchObject({ - _tag: "BrowserRecordingCaptureTimeoutError", - tabId: "recording-tab", - timeoutMs: BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, - }); - await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS); - - await rejection; - await expect(startPromise).rejects.toBeInstanceOf(BrowserRecordingCaptureTimeoutError); - expect(stopScreencast).toHaveBeenCalledWith("recording-tab"); - expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); - expect(useBrowserSurfaceStore.getState().activityByTabId["recording-tab"]).toBeUndefined(); - - finishCapture({ - getVideoTracks: () => [], - getTracks: () => [{ stop: stopTrack }], - } as unknown as MediaStream); - await vi.advanceTimersByTimeAsync(0); - expect(stopTrack).toHaveBeenCalledOnce(); - }); - it("records separate tabs concurrently", async () => { const firstThreadRef = { environmentId: EnvironmentId.make("environment-recording"), @@ -469,133 +278,6 @@ describe("browser recording", () => { expect(save).toHaveBeenCalledTimes(2); }); - it("serializes display media grants for concurrent recording starts", async () => { - let finishFirstCapture!: (stream: MediaStream) => void; - const stream = { - getVideoTracks: () => [], - getTracks: () => [{ stop: vi.fn() }], - } as unknown as MediaStream; - getDisplayMedia - .mockImplementationOnce( - () => - new Promise((resolve) => { - finishFirstCapture = resolve; - }), - ) - .mockResolvedValueOnce(stream); - - const firstStart = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); - const secondStart = startBrowserRecording("recording-tab-2"); - await vi.waitFor(() => expect(readActiveBrowserRecordingTabIds().size).toBe(2)); - - expect(startScreencast).toHaveBeenCalledTimes(1); - finishFirstCapture(stream); - await Promise.all([firstStart, secondStart]); - - expect(startScreencast.mock.calls).toEqual([["recording-tab"], ["recording-tab-2"]]); - expect(getDisplayMedia).toHaveBeenCalledTimes(2); - await Promise.all([ - stopBrowserRecording("recording-tab"), - stopBrowserRecording("recording-tab-2"), - ]); - }); - - it("cancels a queued recording when stopped before its media grant", async () => { - let finishFirstCapture!: (stream: MediaStream) => void; - const stream = { - getVideoTracks: () => [], - getTracks: () => [{ stop: vi.fn() }], - } as unknown as MediaStream; - getDisplayMedia.mockImplementationOnce( - () => - new Promise((resolve) => { - finishFirstCapture = resolve; - }), - ); - - const firstStart = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); - const secondStart = startBrowserRecording("recording-tab-2"); - await vi.waitFor(() => expect(readActiveBrowserRecordingTabIds().size).toBe(2)); - - const secondStop = stopBrowserRecording("recording-tab-2"); - await expect(secondStart).rejects.toBeInstanceOf(BrowserRecordingStartCancelledError); - await expect(secondStop).resolves.toBeNull(); - expect(startScreencast).toHaveBeenCalledTimes(1); - - finishFirstCapture(stream); - await firstStart; - await stopBrowserRecording("recording-tab"); - expect(getDisplayMedia).toHaveBeenCalledOnce(); - }); - - it("latches a stop that arrives before the start becomes queued", async () => { - let releaseDelayedPaint!: (timestamp: number) => void; - let frameId = 0; - vi.stubGlobal( - "requestAnimationFrame", - vi.fn((callback: FrameRequestCallback) => { - frameId += 1; - if (frameId === 1) releaseDelayedPaint = callback; - else callback(frameId); - return frameId; - }), - ); - let finishBlockingCapture!: (stream: MediaStream) => void; - const stream = { - getVideoTracks: () => [], - getTracks: () => [{ stop: vi.fn() }], - } as unknown as MediaStream; - getDisplayMedia.mockImplementationOnce( - () => - new Promise((resolve) => { - finishBlockingCapture = resolve; - }), - ); - - const delayedStart = startBrowserRecording("delayed-tab"); - await vi.waitFor(() => - expect(readActiveBrowserRecordingTabIds().has("delayed-tab")).toBe(true), - ); - const blockingStart = startBrowserRecording("blocking-tab"); - await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); - - const delayedStop = stopBrowserRecording("delayed-tab"); - releaseDelayedPaint(1); - - await expect(delayedStart).rejects.toBeInstanceOf(BrowserRecordingStartCancelledError); - await expect(delayedStop).resolves.toBeNull(); - expect(startScreencast).toHaveBeenCalledOnce(); - - finishBlockingCapture(stream); - await blockingStart; - await stopBrowserRecording("blocking-tab"); - }); - - it("finishes an uncontended pre-grant start before stopping", async () => { - const animationFrames: FrameRequestCallback[] = []; - vi.stubGlobal( - "requestAnimationFrame", - vi.fn((callback: FrameRequestCallback) => { - animationFrames.push(callback); - return animationFrames.length; - }), - ); - - const startPromise = startBrowserRecording("recording-tab"); - await vi.waitFor(() => - expect(readActiveBrowserRecordingTabIds().has("recording-tab")).toBe(true), - ); - const stopPromise = stopBrowserRecording("recording-tab"); - expect(startScreencast).not.toHaveBeenCalled(); - - animationFrames.shift()?.(1); - animationFrames.shift()?.(2); - await startPromise; - await expect(stopPromise).resolves.toMatchObject({ tabId: "recording-tab" }); - }); - it("keeps a recording reachable through its runtime id after a server epoch changes", async () => { const threadRef = { environmentId: EnvironmentId.make("environment-recording"), @@ -619,188 +301,4 @@ describe("browser recording", () => { await stopBrowserRecording(runtimeTabId); }); - - it("does not report success for a second start while the first is still starting", async () => { - let finishStartingScreencast: (() => void) | undefined; - startScreencast.mockImplementationOnce(async () => { - events.push("start-screencast"); - await new Promise((resolve) => { - finishStartingScreencast = resolve; - }); - }); - - const firstStart = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); - - await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( - BrowserRecordingConflictError, - ); - - finishStartingScreencast?.(); - await firstStart; - await stopBrowserRecording("recording-tab"); - }); - - it("does not report success for a start while the recording is stopping", async () => { - let finishStoppingScreencast: (() => void) | undefined; - stopScreencast.mockImplementationOnce(async () => { - await new Promise((resolve) => { - finishStoppingScreencast = resolve; - }); - return undefined; - }); - - await startBrowserRecording("recording-tab"); - const stopPromise = stopBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); - - await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( - BrowserRecordingConflictError, - ); - - finishStoppingScreencast?.(); - await stopPromise; - }); - - it("shares an in-progress stop with duplicate callers", async () => { - let finishStoppingScreencast: (() => void) | undefined; - stopScreencast.mockImplementationOnce(async () => { - await new Promise((resolve) => { - finishStoppingScreencast = resolve; - }); - return undefined; - }); - - await startBrowserRecording("recording-tab"); - const firstStop = stopBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); - const duplicateStop = stopBrowserRecording("recording-tab"); - - finishStoppingScreencast?.(); - const [firstArtifact, duplicateArtifact] = await Promise.all([firstStop, duplicateStop]); - - expect(duplicateArtifact).toEqual(firstArtifact); - expect(stopScreencast).toHaveBeenCalledOnce(); - expect(save).toHaveBeenCalledOnce(); - }); - - it("finishes startup before stopping so an active recording yields an artifact", async () => { - let finishStartingScreencast: (() => void) | undefined; - startScreencast.mockImplementationOnce(async () => { - events.push("start-screencast"); - await new Promise((resolve) => { - finishStartingScreencast = resolve; - }); - }); - - const startPromise = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); - - const stopPromise = stopBrowserRecording("recording-tab"); - expect(stopScreencast).not.toHaveBeenCalled(); - finishStartingScreencast?.(); - - await startPromise; - await expect(stopPromise).resolves.toMatchObject({ tabId: "recording-tab" }); - expect(stopScreencast).toHaveBeenCalledOnce(); - expect(save).toHaveBeenCalledOnce(); - expect(events.at(-1)).toBe("clear"); - }); - - it("does not release the recording slot until a cancelled start settles", async () => { - let finishStartingScreencast: (() => void) | undefined; - startScreencast.mockImplementationOnce(async () => { - events.push("start-screencast"); - await new Promise((resolve) => { - finishStartingScreencast = resolve; - }); - }); - - const firstStart = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); - - const stopPromise = stopBrowserRecording("recording-tab"); - const restartAfterStop = stopPromise.then(() => startBrowserRecording("recording-tab")); - await new Promise((resolve) => setTimeout(resolve, 0)); - const startCallsBeforeFirstSettled = startScreencast.mock.calls.length; - - finishStartingScreencast?.(); - await firstStart; - await stopPromise; - await restartAfterStop; - await stopBrowserRecording("recording-tab"); - - expect(startCallsBeforeFirstSettled).toBe(1); - }); - - it("keeps the recording slot while a failed stop waits for startup", async () => { - let finishStartingScreencast: (() => void) | undefined; - startScreencast.mockImplementationOnce(async () => { - events.push("start-screencast"); - await new Promise((resolve) => { - finishStartingScreencast = resolve; - }); - }); - stopScreencast.mockRejectedValueOnce(new Error("initial stop failed")); - - const firstStart = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); - - const stopPromise = stopBrowserRecording("recording-tab"); - const rejectedStop = expect(stopPromise).rejects.toMatchObject({ - operation: "stop-screencast", - tabId: "recording-tab", - }); - expect(stopScreencast).not.toHaveBeenCalled(); - await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( - BrowserRecordingConflictError, - ); - - finishStartingScreencast?.(); - await firstStart; - await rejectedStop; - expect(stopScreencast).toHaveBeenCalledOnce(); - - await startBrowserRecording("recording-tab"); - await stopBrowserRecording("recording-tab"); - }); - - it("fails a stop that waits too long for startup without freeing the recording slot", async () => { - vi.useFakeTimers(); - let finishStartingScreencast: (() => void) | undefined; - startScreencast.mockImplementationOnce(async () => { - events.push("start-screencast"); - await new Promise((resolve) => { - finishStartingScreencast = resolve; - }); - }); - - const startPromise = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); - - const stopPromise = stopBrowserRecording("recording-tab"); - await vi.advanceTimersByTimeAsync(0); - expect(stopScreencast).not.toHaveBeenCalled(); - - const rejection = expect(stopPromise).rejects.toMatchObject({ - operation: "wait-startup", - tabId: "recording-tab", - }); - await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS); - - await rejection; - expect(save).not.toHaveBeenCalled(); - await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( - BrowserRecordingConflictError, - ); - - finishStartingScreencast?.(); - await vi.advanceTimersByTimeAsync(32); - await startPromise; - const cleanupResult = await stopBrowserRecording("recording-tab"); - expect(cleanupResult).toBeNull(); - expect(stopScreencast).toHaveBeenCalledOnce(); - expect(save).not.toHaveBeenCalled(); - expect(events.at(-1)).toBe("clear"); - }); }); diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 2704c56aeebf..9a78b94d0072 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -1,4 +1,7 @@ -import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; +import { + DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, + PreviewCaptureFailure, +} from "@t3tools/contracts"; import type { DesktopPreviewRecordingArtifact, ScopedThreadRef } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; @@ -12,6 +15,8 @@ import { createRecordingCompositor } from "./recordingCompositor"; import { acquireBrowserSurfaceActivity } from "./browserSurfaceStore"; +const isPreviewCaptureFailure = Schema.is(PreviewCaptureFailure); + export class BrowserRecordingUnavailableError extends Schema.TaggedError()( "BrowserRecordingUnavailableError", { @@ -86,6 +91,22 @@ export class BrowserRecordingOperationError extends Schema.TaggedError { - it("stops the only active recording when the implicit browser target changed", () => { - expect(resolveBrowserRecordingStopTarget(new Set(["tab-recording"]), "tab-browsing")).toBe( - "tab-recording", - ); - }); - - it("prefers an implicit target that is actively recording", () => { - expect( - resolveBrowserRecordingStopTarget( - new Set(["tab-recording-a", "tab-recording-b"]), - "tab-recording-b", - ), - ).toBe("tab-recording-b"); - }); - - it("does not guess when multiple recordings are active and the implicit target is not one", () => { - expect( - resolveBrowserRecordingStopTarget( - new Set(["tab-recording-a", "tab-recording-b"]), - "tab-browsing", - ), - ).toBeNull(); - }); - - it("only stops an explicitly requested tab when that tab is recording", () => { - const activeTabIds = new Set(["tab-recording"]); - expect(resolveBrowserRecordingStopTarget(activeTabIds, "tab-browsing", "tab-recording")).toBe( - "tab-recording", - ); - expect(resolveBrowserRecordingStopTarget(activeTabIds, "tab-recording", "tab-browsing")).toBe( - null, - ); - }); - - it("returns null when no matching recording is active", () => { - expect(resolveBrowserRecordingStopTarget(new Set(), "tab-browsing")).toBeNull(); - }); -}); diff --git a/apps/web/src/browser/browserRecordingScope.ts b/apps/web/src/browser/browserRecordingScope.ts deleted file mode 100644 index ffbcc19a5453..000000000000 --- a/apps/web/src/browser/browserRecordingScope.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function resolveBrowserRecordingStopTarget( - activeTabIds: ReadonlySet, - implicitTabId: string | null, - explicitTabId?: string, -): string | null { - if (explicitTabId !== undefined) { - return activeTabIds.has(explicitTabId) ? explicitTabId : null; - } - if (implicitTabId !== null && activeTabIds.has(implicitTabId)) { - return implicitTabId; - } - if (activeTabIds.size !== 1) return null; - return activeTabIds.values().next().value ?? null; -} diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index 456377a4d641..2630d8fe0e3e 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { acquireBrowserSurface, acquireBrowserSurfaceActivity, - resolveBrowserSurfacePanelRect, useBrowserSurfaceStore, } from "./browserSurfaceStore"; @@ -52,86 +51,6 @@ describe("browserSurfaceStore", () => { fittedLease.release(); }); - it("freezes the first content dimensions when fitting starts before the browser is measured", () => { - const tabId = "pending-fitted-browser-surface"; - const fittedLease = acquireBrowserSurface(tabId, true); - const sourceContent = { - x: 0, - y: 0, - width: 1_280, - height: 720, - scale: 1, - scrollLeft: 0, - scrollTop: 0, - }; - - useBrowserSurfaceStore.getState().presentContent(tabId, sourceContent); - useBrowserSurfaceStore.getState().presentContent(tabId, { - ...sourceContent, - width: 320, - height: 180, - scale: 0.25, - }); - - expect(useBrowserSurfaceStore.getState().byTabId[tabId]?.fittedSourceContent).toEqual( - sourceContent, - ); - fittedLease.release(); - }); - - it("tracks content dimensions for a browser that has never been visible", () => { - const tabId = "hidden-browser-surface-content-test"; - useBrowserSurfaceStore.getState().presentContent(tabId, { - x: 0, - y: 0, - width: 393, - height: 852, - scale: 1, - scrollLeft: 0, - scrollTop: 0, - }); - - expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ - rect: null, - visible: false, - content: { width: 393, height: 852 }, - }); - }); - - it("keeps a hidden background tab on its own last rect", () => { - const staleRect = { x: 0, y: 0, width: 500, height: 700 }; - const liveRect = { x: 10, y: 20, width: 900, height: 640 }; - expect( - resolveBrowserSurfacePanelRect( - { - hidden: { - rect: staleRect, - visible: false, - zIndex: 30, - content: null, - fittedSourceContent: null, - fitSourceContent: false, - cornerRadius: 0, - updatedAt: 1, - owner: null, - }, - active: { - rect: liveRect, - visible: true, - zIndex: 30, - content: null, - fittedSourceContent: null, - fitSourceContent: false, - cornerRadius: 0, - updatedAt: 2, - owner: null, - }, - }, - "hidden", - ), - ).toEqual(staleRect); - }); - it("ignores updates and releases from a stale surface lease", () => { const tabId = "leased-browser-surface"; const staleRect = { x: 0, y: 0, width: 500, height: 700 }; @@ -149,52 +68,4 @@ describe("browserSurfaceStore", () => { visible: true, }); }); - - it("hides a surface when its current lease is released", () => { - const tabId = "released-browser-surface"; - const lease = acquireBrowserSurface(tabId); - lease.present({ x: 10, y: 20, width: 900, height: 640 }, true); - - lease.release(); - lease.present({ x: 0, y: 0, width: 1, height: 1 }, true); - - expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ - visible: false, - owner: null, - }); - }); - - it("keeps the requested layer with the active surface lease", () => { - const tabId = "layered-browser-surface"; - const lease = acquireBrowserSurface(tabId); - lease.present({ x: 10, y: 20, width: 320, height: 200 }, true, 12, 48); - - expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ - visible: true, - zIndex: 48, - }); - }); - - it("clears fitted presentation state when its lease is released", () => { - const tabId = "released-fitted-browser-surface"; - const fittedLease = acquireBrowserSurface(tabId, true); - useBrowserSurfaceStore.getState().presentContent(tabId, { - x: 0, - y: 0, - width: 1_280, - height: 800, - scale: 1, - scrollLeft: 0, - scrollTop: 0, - }); - - fittedLease.release(); - - expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ - fittedSourceContent: null, - fitSourceContent: false, - owner: null, - visible: false, - }); - }); }); diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index c2b6c9ea64b4..39afd5ec816b 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -43,22 +43,6 @@ describe("browser target resolver", () => { }); }); - it("preserves explicit IPv4 loopback URL navigation for a private network environment", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.50:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "url", - url: "http://127.0.0.1:5999/", - }), - ).toEqual({ - requestedUrl: "http://127.0.0.1:5999/", - resolvedUrl: "http://127.0.0.1:5999/", - resolutionKind: "direct", - environmentId: "environment-1", - }); - }); - it("preserves URL credentials on explicit loopback navigation", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); @@ -70,19 +54,6 @@ describe("browser target resolver", () => { ).toBe("http://user:p%40ss@localhost:5173/dashboard"); }); - it("preserves credentialed loopback URLs for private IPv6 environments", async () => { - readPreparedConnection.mockReturnValue({ - httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", - }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "url", - url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", - }).resolvedUrl, - ).toBe("http://user:p%40ss@localhost:5173/dashboard?mode=test#results"); - }); - it("preserves schemeless localhost navigation for a remote environment", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); @@ -94,38 +65,6 @@ describe("browser target resolver", () => { ).toBe("localhost:3000/app"); }); - it("keeps localhost navigation local for a local environment", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "url", - url: "localhost:3000/app", - }), - ).toEqual({ - requestedUrl: "localhost:3000/app", - resolvedUrl: "localhost:3000/app", - resolutionKind: "direct", - environmentId: "environment-1", - }); - }); - - it("keeps localhost navigation local for the full IPv4 loopback range", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.2:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "url", - url: "http://localhost:3000/app", - }), - ).toEqual({ - requestedUrl: "http://localhost:3000/app", - resolvedUrl: "http://localhost:3000/app", - resolutionKind: "direct", - environmentId: "environment-1", - }); - }); - it("refuses public relay hosts until the authenticated gateway exists", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); @@ -189,63 +128,6 @@ describe("browser target resolver", () => { } }); - it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); - const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); - expect( - resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173/app?x=1#top"), - ).toBe("http://localhost:5173/app?x=1#top"); - }); - - it("normalizes public URLs without treating them as environment ports", async () => { - const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); - expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "example.com/app")).toBe( - "https://example.com/app", - ); - }); - - it("supports private IPv6 environment hosts", async () => { - readPreparedConnection.mockReturnValue({ - httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", - }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "environment-port", - port: 5173, - path: "/app?mode=test", - }).resolvedUrl, - ).toBe("http://[fd7a:115c:a1e0::53]:5173/app?mode=test"); - }); - - it("supports a local IPv6 environment host", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "environment-port", - port: 5173, - }).resolvedUrl, - ).toBe("http://localhost:5173/"); - }); - - it("maps local IPv4 environment ports onto localhost for dual-stack guests", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "environment-port", - port: 5173, - path: "/app", - }).resolvedUrl, - ).toBe("http://localhost:5173/app"); - }); - - it("leaves malformed input for the normal navigation error path", async () => { - const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); - expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), " ")).toBe(" "); - }); - it("classifies exact private IPv4 and IPv6 boundaries", async () => { const { isPrivateNetworkHost } = await import("./browserTargetResolver"); const privateHosts = [ diff --git a/apps/web/src/browser/browserViewportActions.test.ts b/apps/web/src/browser/browserViewportActions.test.ts index a7633fd0963d..a441eecf08dd 100644 --- a/apps/web/src/browser/browserViewportActions.test.ts +++ b/apps/web/src/browser/browserViewportActions.test.ts @@ -1,39 +1,11 @@ -import type { PreviewViewportSetting } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { - BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS, commitBrowserViewportChange, - runBrowserViewportMutation, subscribeBrowserViewportChange, } from "./browserViewportActions"; describe("browserViewportActions", () => { - it("routes drag commits to the visible tab handler and cleans up exactly that handler", async () => { - const first = vi.fn(async () => undefined); - const second = vi.fn(async () => undefined); - const unsubscribeFirst = subscribeBrowserViewportChange("tab-1", first); - const unsubscribeSecond = subscribeBrowserViewportChange("tab-1", second); - - unsubscribeFirst(); - await commitBrowserViewportChange("tab-1", { - _tag: "freeform", - width: 900, - height: 700, - }); - expect(first).not.toHaveBeenCalled(); - expect(second).toHaveBeenCalledWith({ _tag: "freeform", width: 900, height: 700 }); - - unsubscribeSecond(); - await expect( - commitBrowserViewportChange("tab-1", { - _tag: "freeform", - width: 800, - height: 600, - }), - ).rejects.toThrow("No visible browser viewport handler"); - }); - it("commits viewport changes in order for each tab", async () => { let releaseFirst: (() => void) | undefined; let markFirstStarted: (() => void) | undefined; @@ -71,70 +43,4 @@ describe("browserViewportActions", () => { expect(calls).toEqual([800, 900]); unsubscribe(); }); - - it("serializes background mutations with visible viewport commits", async () => { - let releaseBackground: (() => void) | undefined; - const backgroundPending = new Promise((resolve) => { - releaseBackground = resolve; - }); - const calls: string[] = []; - const background = runBrowserViewportMutation("tab-shared", async () => { - calls.push("background"); - await backgroundPending; - }); - const unsubscribe = subscribeBrowserViewportChange("tab-shared", async () => { - calls.push("visible"); - }); - const visible = commitBrowserViewportChange("tab-shared", { - _tag: "freeform", - width: 900, - height: 700, - }); - - await vi.waitFor(() => expect(calls).toEqual(["background"])); - releaseBackground?.(); - await Promise.all([background, visible]); - - expect(calls).toEqual(["background", "visible"]); - unsubscribe(); - }); - - it("does not let a timed-out handler overtake a newer viewport commit", async () => { - vi.useFakeTimers(); - try { - let releaseFirst: (() => void) | undefined; - const delayed = new Promise((resolve) => { - releaseFirst = resolve; - }); - const handler = vi.fn(async (_setting: PreviewViewportSetting): Promise => undefined); - handler.mockImplementationOnce(() => delayed).mockResolvedValueOnce(undefined); - const unsubscribe = subscribeBrowserViewportChange("tab-timeout", handler); - const first = commitBrowserViewportChange("tab-timeout", { - _tag: "freeform", - width: 800, - height: 600, - }); - const firstResult = expect(first).rejects.toThrow( - "Timed out committing the browser viewport for tab tab-timeout", - ); - const second = commitBrowserViewportChange("tab-timeout", { - _tag: "freeform", - width: 900, - height: 700, - }); - - await vi.advanceTimersByTimeAsync(BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS); - await firstResult; - expect(handler).toHaveBeenCalledTimes(1); - - releaseFirst?.(); - await second; - - expect(handler).toHaveBeenCalledTimes(2); - expect(handler.mock.calls[1]?.[0]).toMatchObject({ width: 900, height: 700 }); - unsubscribe(); - } finally { - vi.useRealTimers(); - } - }); }); diff --git a/apps/web/src/browser/browserViewportLayout.test.ts b/apps/web/src/browser/browserViewportLayout.test.ts index ca2832bea6ce..1932a634fb6c 100644 --- a/apps/web/src/browser/browserViewportLayout.test.ts +++ b/apps/web/src/browser/browserViewportLayout.test.ts @@ -2,152 +2,16 @@ import { describe, expect, it } from "vite-plus/test"; import { resizeBrowserViewportFromRail, - resizeFreeformViewport, - resolveBrowserDeviceViewportLayout, resolveFittedBrowserViewport, - resolveBrowserViewportLayout, - resolveResponsiveBrowserViewportSize, } from "./browserViewportLayout"; describe("resolveBrowserViewportLayout", () => { - it("uses the current fixed viewport instead of stale fitted source dimensions", () => { - expect( - resolveFittedBrowserViewport( - { _tag: "freeform", width: 900, height: 600 }, - { width: 1280, height: 800, scale: 1 }, - ), - ).toEqual({ _tag: "freeform", width: 900, height: 600 }); - }); - it("preserves the last logical viewport when fitting a fill-mode surface", () => { expect( resolveFittedBrowserViewport({ _tag: "fill" }, { width: 320, height: 200, scale: 0.25 }), ).toEqual({ _tag: "freeform", width: 1280, height: 800 }); }); - it("fills the available surface in fill mode", () => { - expect(resolveBrowserViewportLayout({ width: 700, height: 500 }, { _tag: "fill" })).toEqual({ - canvasWidth: 700, - canvasHeight: 500, - viewportX: 0, - viewportY: 0, - viewportWidth: 700, - viewportHeight: 500, - viewportScale: 1, - fillsPanel: true, - }); - }); - - it("centers a smaller fixed viewport", () => { - expect( - resolveBrowserViewportLayout( - { width: 700, height: 1000 }, - { _tag: "freeform", width: 393, height: 852 }, - ), - ).toMatchObject({ - canvasWidth: 700, - canvasHeight: 1000, - viewportX: 154, - viewportY: 74, - viewportWidth: 393, - viewportHeight: 852, - }); - }); - - it("scales a larger fixed viewport down to fit without creating overflow", () => { - const layout = resolveBrowserViewportLayout( - { width: 600, height: 700 }, - { _tag: "freeform", width: 1440, height: 900 }, - ); - expect(layout).toMatchObject({ - canvasWidth: 600, - canvasHeight: 700, - viewportX: 0, - viewportY: 163, - viewportWidth: 600, - viewportHeight: 375, - }); - expect(layout.viewportScale).toBeCloseTo(5 / 12); - }); - - it("keeps fixed dimensions in page CSS pixels when browser zoom changes", () => { - expect( - resolveBrowserViewportLayout( - { width: 800, height: 700 }, - { _tag: "freeform", width: 400, height: 300 }, - 1.5, - ), - ).toMatchObject({ - viewportX: 100, - viewportY: 125, - viewportWidth: 600, - viewportHeight: 450, - }); - expect(resizeFreeformViewport({ width: 400, height: 300 }, { x: 150, y: 75 }, 1.5)).toEqual({ - width: 500, - height: 350, - }); - }); - - it("bounds freeform drag sizes and total render area", () => { - expect(resizeFreeformViewport({ width: 1024, height: 768 }, { x: -2000, y: -2000 })).toEqual({ - width: 240, - height: 240, - }); - const large = resizeFreeformViewport({ width: 1920, height: 1080 }, { x: 2000, y: 2000 }); - expect(large.width * large.height).toBeLessThanOrEqual(3840 * 2160); - }); - - it("resizes only the axes controlled by each edge", () => { - expect( - resizeFreeformViewport({ width: 800, height: 600 }, { x: -100, y: 500 }, 1, "west"), - ).toEqual({ width: 900, height: 600 }); - expect( - resizeFreeformViewport({ width: 800, height: 600 }, { x: 500, y: 100 }, 1, "north"), - ).toEqual({ width: 800, height: 500 }); - expect( - resizeFreeformViewport({ width: 800, height: 600 }, { x: -100, y: -50 }, 1, "northwest"), - ).toEqual({ width: 900, height: 650 }); - }); - - it("preserves a locked aspect ratio from either axis", () => { - expect( - resizeFreeformViewport({ width: 800, height: 600 }, { x: 200, y: 0 }, 1, "east", 4 / 3), - ).toEqual({ width: 1000, height: 750 }); - expect( - resizeFreeformViewport({ width: 800, height: 600 }, { x: 0, y: 150 }, 1, "south", 4 / 3), - ).toEqual({ width: 1000, height: 750 }); - }); - - it("reserves persistent device-toolbar rails around the guest viewport", () => { - expect( - resolveBrowserDeviceViewportLayout( - { width: 1200, height: 900 }, - { _tag: "freeform", width: 1180, height: 852 }, - ), - ).toEqual({ - canvasWidth: 1200, - canvasHeight: 900, - viewportX: 10, - viewportY: 38, - viewportWidth: 1180, - viewportHeight: 852, - viewportScale: 1, - fillsPanel: false, - }); - }); - - it("captures the available framed area when responsive mode is enabled", () => { - expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({ - width: 1180, - height: 852, - }); - expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({ - width: 590, - height: 426, - }); - }); - it("keeps the grabbed rail under the pointer across centered layout boundaries", () => { const available = { width: 1120, height: 818 }; expect( diff --git a/apps/web/src/browser/browserViewportLayout.ts b/apps/web/src/browser/browserViewportLayout.ts index 9634cd8d270e..2fe4e035100a 100644 --- a/apps/web/src/browser/browserViewportLayout.ts +++ b/apps/web/src/browser/browserViewportLayout.ts @@ -38,6 +38,12 @@ export const browserViewportSettingKey = (setting: PreviewViewportSetting): stri const normalizeZoomFactor = (zoomFactor: number): number => Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; +// Convert guest CSS pixels to host CSS pixels without changing the guest's page zoom. +export const resolveBrowserLayoutZoomFactor = ( + previewZoomFactor: number, + windowZoomFactor: number, +): number => normalizeZoomFactor(previewZoomFactor) / normalizeZoomFactor(windowZoomFactor); + export function resolveFittedBrowserViewport( setting: PreviewViewportSetting, sourceContent: { diff --git a/apps/web/src/browser/desktopTabLifetime.test.ts b/apps/web/src/browser/desktopTabLifetime.test.ts index 63e0fde1b3a7..d6bb4cae5932 100644 --- a/apps/web/src/browser/desktopTabLifetime.test.ts +++ b/apps/web/src/browser/desktopTabLifetime.test.ts @@ -22,7 +22,6 @@ vi.mock("./browserRecording", () => ({ })); import { acquireDesktopTab } from "./desktopTabLifetime"; -import * as browserDefaults from "./browserDefaults"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; const DEFAULT_TAB_STATE = { @@ -46,59 +45,6 @@ describe("desktopTabLifetime", () => { vi.restoreAllMocks(); }); - it("does not create a desktop tab after a failed settings read and permits a later retry", async () => { - vi.useFakeTimers(); - const failure = new Error("Settings read failed"); - vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure); - const failed = acquireDesktopTab("tab_settings_retry"); - - await expect(failed.ready).rejects.toBe(failure); - expect(createTab).not.toHaveBeenCalled(); - failed.release(); - await vi.advanceTimersByTimeAsync(0); - - __setClientSettingsForTests({ - ...DEFAULT_CLIENT_SETTINGS, - browserDefaultZoomFactor: 1.25, - browserDefaultAppearance: "dark", - }); - createTab.mockResolvedValueOnce(undefined); - const retry = acquireDesktopTab("tab_settings_retry"); - await retry.ready; - - expect(createTab).toHaveBeenCalledExactlyOnceWith("tab_settings_retry", { - zoomFactor: 1.25, - colorScheme: "dark", - }); - retry.release(); - await vi.advanceTimersByTimeAsync(0); - }); - - it("shares tab creation readiness across concurrent leases", async () => { - let resolveCreation: (() => void) | undefined; - createTab.mockReturnValueOnce( - new Promise((resolve) => { - resolveCreation = resolve; - }), - ); - - const first = acquireDesktopTab("tab_readiness"); - const second = acquireDesktopTab("tab_readiness"); - - expect(first.ready).toBe(second.ready); - - let ready = false; - void first.ready.then(() => { - ready = true; - }); - await vi.waitFor(() => expect(createTab).toHaveBeenCalledOnce()); - expect(ready).toBe(false); - - resolveCreation?.(); - await first.ready; - expect(ready).toBe(true); - }); - it("keeps identical server tab ids from two environments in separate desktop slots", async () => { vi.useFakeTimers(); createTab.mockResolvedValue(undefined); diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts deleted file mode 100644 index 831167095fa1..000000000000 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - HIDDEN_BROWSER_WEBVIEW_OFFSET, - resolveHostedBrowserWebviewWrapperStyle, -} from "./hostedBrowserWebviewStyle"; - -describe("resolveHostedBrowserWebviewWrapperStyle", () => { - it("places an active webview on its presented surface", () => { - expect( - resolveHostedBrowserWebviewWrapperStyle({ - active: true, - renderingActive: true, - rect: { x: 12, y: 34, width: 800, height: 600 }, - hiddenSize: { width: 1280, height: 800 }, - }), - ).toEqual({ - left: 12, - top: 34, - width: 800, - height: 600, - zIndex: 30, - pointerEvents: "auto", - }); - }); - - it("clips a floating webview to the mini-player frame", () => { - expect( - resolveHostedBrowserWebviewWrapperStyle({ - active: true, - renderingActive: true, - cornerRadius: 12, - zIndex: 48, - rect: { x: 12, y: 34, width: 360, height: 203 }, - hiddenSize: { width: 1280, height: 800 }, - }), - ).toMatchObject({ - left: 12, - top: 34, - width: 360, - height: 203, - borderRadius: 12, - zIndex: 48, - }); - }); - - it("suspends painting for an inactive webview", () => { - const style = resolveHostedBrowserWebviewWrapperStyle({ - active: false, - renderingActive: false, - rect: { x: 12, y: 34, width: 800, height: 600 }, - hiddenSize: { width: 393, height: 852 }, - }); - - expect(style).toEqual({ - left: HIDDEN_BROWSER_WEBVIEW_OFFSET, - top: HIDDEN_BROWSER_WEBVIEW_OFFSET, - width: 393, - height: 852, - zIndex: -1, - pointerEvents: "none", - visibility: "hidden", - }); - }); - - it("keeps an active background task paintable behind the app", () => { - const style = resolveHostedBrowserWebviewWrapperStyle({ - active: false, - renderingActive: true, - rect: null, - hiddenSize: { width: 1280, height: 800 }, - }); - - expect(style).toEqual({ - left: 0, - top: 0, - width: 1280, - height: 800, - zIndex: -1, - pointerEvents: "none", - visibility: "visible", - }); - }); - - it("keeps an inactive webview paintable without marking it as rendering-active", () => { - const style = resolveHostedBrowserWebviewWrapperStyle({ - active: false, - renderingActive: false, - keepPaintableWhenInactive: true, - rect: null, - hiddenSize: { width: 1280, height: 800 }, - }); - - expect(style).toEqual({ - left: HIDDEN_BROWSER_WEBVIEW_OFFSET, - top: HIDDEN_BROWSER_WEBVIEW_OFFSET, - width: 1280, - height: 800, - zIndex: -1, - pointerEvents: "none", - visibility: "visible", - }); - }); -}); diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index 5bdf9b7c4f6d..420fdb9cc39a 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -16,7 +16,7 @@ export interface HostedBrowserWebviewWrapperStyle { readonly visibility?: "hidden" | "visible"; } -export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; +const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; export function resolveHostedBrowserWebviewWrapperStyle(input: { readonly active: boolean; diff --git a/apps/web/src/browser/previewRuntime.test.ts b/apps/web/src/browser/previewRuntime.test.ts deleted file mode 100644 index 951b52e90468..000000000000 --- a/apps/web/src/browser/previewRuntime.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { EnvironmentId } from "@t3tools/contracts"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -const state = vi.hoisted(() => ({ - desktop: false, - primary: null as string | null, - serverBrowser: new Set(), -})); - -vi.mock("~/env", () => ({ isElectron: false })); -vi.mock("~/previewStateStore", () => ({ isPreviewSupportedInRuntime: () => state.desktop })); -vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: { get: () => state.primary } })); -vi.mock("~/state/primaryEnvironment", () => ({ primaryEnvironmentIdAtom: {} })); -vi.mock("~/state/entities", () => ({ - readEnvironmentSupportsServerBrowser: (id: string) => state.serverBrowser.has(id), - useEnvironmentSupportsServerBrowser: () => false, -})); - -import { alternatePreviewRuntime, previewRuntimeFor } from "./previewRuntime"; - -const local = "local" as EnvironmentId; -const remote = "remote" as EnvironmentId; - -afterEach(() => { - state.desktop = false; - state.primary = null; - state.serverBrowser = new Set(); -}); - -describe("previewRuntimeFor", () => { - it("opens a remote environment's tabs on this computer in the desktop app", () => { - state.desktop = true; - state.primary = local; - state.serverBrowser = new Set([local, remote]); - - expect(previewRuntimeFor(remote)).toBeUndefined(); - expect(previewRuntimeFor(local)).toBe("server"); - }); - - it("uses the environment's browser where the client has none of its own", () => { - state.serverBrowser = new Set([remote]); - - expect(previewRuntimeFor(remote)).toBe("server"); - }); -}); - -describe("alternatePreviewRuntime", () => { - it("moves a remote environment's tab between this computer and the environment", () => { - state.desktop = true; - - expect(alternatePreviewRuntime(remote, local, true, {})).toBe("server"); - expect(alternatePreviewRuntime(remote, local, true, { runtime: "server" })).toBe("desktop"); - expect(alternatePreviewRuntime(local, local, true, {})).toBeNull(); - expect(alternatePreviewRuntime(remote, local, false, {})).toBeNull(); - }); - - it("offers no move outside the desktop app", () => { - expect(alternatePreviewRuntime(remote, null, true, { runtime: "server" })).toBeNull(); - }); -}); diff --git a/apps/web/src/browser/previewRuntimeTabId.test.ts b/apps/web/src/browser/previewRuntimeTabId.test.ts index 7518ede3e5e7..9cb0114e1524 100644 --- a/apps/web/src/browser/previewRuntimeTabId.test.ts +++ b/apps/web/src/browser/previewRuntimeTabId.test.ts @@ -4,35 +4,6 @@ import { describe, expect, it } from "vite-plus/test"; import { isCurrentPreviewRuntimeTab, previewRuntimeTabId } from "./previewRuntimeTabId"; describe("previewRuntimeTabId", () => { - it("scopes process-local tab ids to their environment, thread, and server epoch", () => { - const base = { - environmentId: EnvironmentId.make("environment-a"), - threadId: ThreadId.make("thread-a"), - }; - - expect(previewRuntimeTabId(base, "epoch-a", "tab_1")).not.toBe( - previewRuntimeTabId( - { ...base, environmentId: EnvironmentId.make("environment-b") }, - "epoch-a", - "tab_1", - ), - ); - expect(previewRuntimeTabId(base, "epoch-a", "tab_1")).not.toBe( - previewRuntimeTabId({ ...base, threadId: ThreadId.make("thread-b") }, "epoch-a", "tab_1"), - ); - expect(previewRuntimeTabId(base, "epoch-a", "tab_1")).not.toBe( - previewRuntimeTabId(base, "epoch-b", "tab_1"), - ); - }); - - it("is stable for the same runtime tab", () => { - const ref = { - environmentId: EnvironmentId.make("environment-a"), - threadId: ThreadId.make("thread-a"), - }; - expect(previewRuntimeTabId(ref, null, "tab_1")).toBe(previewRuntimeTabId(ref, null, "tab_1")); - }); - it("rejects a pinned operation target after the server epoch changes", () => { const ref = { environmentId: EnvironmentId.make("environment-a"), diff --git a/apps/web/src/browser/previewWebviewConfigState.test.ts b/apps/web/src/browser/previewWebviewConfigState.test.ts deleted file mode 100644 index 75704cd2da2b..000000000000 --- a/apps/web/src/browser/previewWebviewConfigState.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; - -import { - loadPreviewWebviewConfig, - PreviewWebviewBridgeUnavailableError, - PreviewWebviewConfigLoadError, -} from "./previewWebviewConfigState"; - -const environmentId = EnvironmentId.make("environment-1"); - -describe("loadPreviewWebviewConfig", () => { - it.effect("reports a structurally distinct missing-bridge failure", () => - Effect.gen(function* () { - const error = yield* loadPreviewWebviewConfig(environmentId, undefined, null).pipe( - Effect.flip, - ); - - expect(error).toBeInstanceOf(PreviewWebviewBridgeUnavailableError); - expect(error.environmentId).toBe(environmentId); - expect(error.message).toContain(environmentId); - expect("cause" in error).toBe(false); - }), - ); - - it.effect("preserves the bridge rejection as the load failure cause", () => - Effect.gen(function* () { - const cause = new Error("ipc unavailable"); - const error = yield* loadPreviewWebviewConfig(environmentId, undefined, { - getPreviewConfig: () => Promise.reject(cause), - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(PreviewWebviewConfigLoadError); - expect(error.environmentId).toBe(environmentId); - expect(error.cause).toBe(cause); - expect(error.message).not.toContain(cause.message); - }), - ); - - it.effect("forwards the environment id and profile to the bridge", () => - Effect.gen(function* () { - let requested: { environmentId: EnvironmentId; profileId: string | undefined } | null = null; - const config = { - partition: "persist:test-preview", - webPreferences: "sandbox=yes", - preloadUrl: null, - }; - const result = yield* loadPreviewWebviewConfig(environmentId, "work", { - getPreviewConfig: (requestedEnvironmentId, profileId) => { - requested = { environmentId: requestedEnvironmentId, profileId }; - return Promise.resolve(config); - }, - }); - - expect(requested).toEqual({ environmentId, profileId: "work" }); - expect(result).toEqual(config); - }), - ); -}); diff --git a/apps/web/src/browser/previewWebviewConfigState.ts b/apps/web/src/browser/previewWebviewConfigState.ts index 312dde29a66c..aa46b4c84527 100644 --- a/apps/web/src/browser/previewWebviewConfigState.ts +++ b/apps/web/src/browser/previewWebviewConfigState.ts @@ -43,7 +43,7 @@ export type PreviewWebviewConfigError = typeof PreviewWebviewConfigError.Type; type PreviewConfigBridge = Pick; -export const loadPreviewWebviewConfig = ( +const loadPreviewWebviewConfig = ( environmentId: EnvironmentId, profileId?: string, bridge: PreviewConfigBridge | null = previewBridge, diff --git a/apps/web/src/browser/recordingCompositor.test.ts b/apps/web/src/browser/recordingCompositor.test.ts deleted file mode 100644 index d10e69d24663..000000000000 --- a/apps/web/src/browser/recordingCompositor.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { DesktopPreviewRecordingInput } from "@t3tools/contracts"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { createRecordingCompositor, RecordingDecorations } from "./recordingCompositor"; - -const primaryColor = "oklch(0.65 0.2 310)"; -vi.mock("./annotationTheme", () => ({ - readPreviewAnnotationTheme: () => ({ primary: "oklch(0.65 0.2 310)" }), -})); - -const options = { showKeyPresses: true, showMousePresses: true, frameRate: 30 }; -const pointer = ( - phase: "move" | "down" | "up" | "click", - x = 100, -): DesktopPreviewRecordingInput => ({ - type: "pointer", - phase, - x, - y: 80, - width: 800, - height: 600, -}); -const context = () => ({ - save: vi.fn(), - restore: vi.fn(), - beginPath: vi.fn(), - fill: vi.fn(), - stroke: vi.fn(), - ellipse: vi.fn(), - roundRect: vi.fn(), - fillText: vi.fn(), - drawImage: vi.fn(), - measureText: () => ({ width: 40 }), - globalAlpha: 1, - strokeStyle: "", - fillStyle: "", -}); - -describe("recording decorations", () => { - it("keeps rings aligned through dragging and stops following the cursor after release", () => { - const decorations = new RecordingDecorations(options, primaryColor); - const ctx = context(); - decorations.apply(pointer("down"), 0); - decorations.apply(pointer("move", 120), 10); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 10); - expect(ctx.ellipse.mock.calls[0]?.slice(0, 4)).toEqual([240, 160, 40, 40]); - expect(ctx.strokeStyle).toBe(primaryColor); - expect(ctx.fillStyle).toBe(primaryColor); - expect(decorations.nextRedraw(10)).toBeNull(); - decorations.apply(pointer("up", 130), 20); - decorations.apply(pointer("move", 300), 30); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 320); - expect(ctx.ellipse.mock.calls[1]?.slice(0, 4)).toEqual([260, 160, 50, 50]); - ctx.ellipse.mockClear(); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 620); - expect(ctx.ellipse).not.toHaveBeenCalled(); - expect(decorations.nextRedraw(620)).toBeNull(); - }); - - it("pulses agent clicks and clears decorations on blur or navigation", () => { - const decorations = new RecordingDecorations(options, primaryColor); - const ctx = context(); - decorations.apply(pointer("click"), 0); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 300); - expect(ctx.ellipse).toHaveBeenCalledOnce(); - decorations.apply({ type: "clear" }, 301); - ctx.ellipse.mockClear(); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 302); - expect(ctx.ellipse).not.toHaveBeenCalled(); - expect(decorations.nextRedraw(302)).toBeNull(); - }); - - it("holds shortcut badges until release, then expires them even on a static page", () => { - const decorations = new RecordingDecorations(options, primaryColor); - const ctx = context(); - const key = { type: "key" as const, label: "⌘C", held: true, width: 800 }; - decorations.apply(key, 0); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 5000); - expect(ctx.fillText.mock.calls[0]?.slice(0, 3)).toEqual(["⌘C", 800, 1098]); - decorations.apply({ ...key, held: false }, 5000); - expect(decorations.nextRedraw(5500)).toBe(400); - ctx.fillText.mockClear(); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 5900); - expect(ctx.fillText).not.toHaveBeenCalled(); - }); - - it("removes the previous key badge on password focus", () => { - const decorations = new RecordingDecorations(options, primaryColor); - const ctx = context(); - decorations.apply({ type: "key", label: "A", held: true, width: 800 }, 0); - decorations.apply({ type: "key", label: null, held: true, width: 800 }, 1); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 2); - expect(ctx.fillText).not.toHaveBeenCalled(); - }); - - it("honors independent opt-in flags", () => { - const decorations = new RecordingDecorations( - { ...options, showMousePresses: false }, - primaryColor, - ); - const ctx = context(); - decorations.apply(pointer("down"), 0); - decorations.apply({ type: "key", label: "⌘C", held: true, width: 800 }, 0); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 1); - expect(ctx.ellipse).not.toHaveBeenCalled(); - expect(ctx.fillText).toHaveBeenCalledOnce(); - }); -}); - -describe("detached recording compositor", () => { - afterEach(() => vi.unstubAllGlobals()); - - it("keeps native capture when both decorations are off", async () => { - vi.stubGlobal("document", { - createElement: () => { - throw new Error("must not allocate"); - }, - }); - expect( - await createRecordingCompositor( - {} as MediaStream, - { - ...options, - showKeyPresses: false, - showMousePresses: false, - }, - () => { - throw new Error("must not subscribe"); - }, - ), - ).toBeNull(); - }); - - it.each([false, true])( - "releases the detached output on disposal or playback failure (%s)", - async (failPlayback) => { - const ctx = context(); - const stop = vi.fn(); - const unsubscribe = vi.fn(); - const cancelFrame = vi.fn(); - const source = { - getVideoTracks: () => [{ getSettings: () => ({ width: 800, height: 600 }) }], - } as unknown as MediaStream; - const stream = { getTracks: () => [{ stop }] } as unknown as MediaStream; - const canvas = { width: 0, height: 0, getContext: () => ctx, captureStream: () => stream }; - const video = { - muted: false, - playsInline: false, - srcObject: null as MediaStream | null, - readyState: 2, - videoWidth: 800, - videoHeight: 600, - pause: vi.fn(), - play: async () => { - if (failPlayback) throw new Error("play failed"); - }, - requestVideoFrameCallback: () => 1, - cancelVideoFrameCallback: cancelFrame, - }; - vi.stubGlobal("document", { - createElement: (tag: string) => (tag === "canvas" ? canvas : video), - }); - vi.stubGlobal("window", { clearTimeout: vi.fn(), setTimeout: vi.fn() }); - const compositor = createRecordingCompositor(source, options, () => unsubscribe); - if (failPlayback) await expect(compositor).rejects.toThrow("play failed"); - else { - const result = await compositor; - expect(result?.stream).toBe(stream); - expect(ctx.drawImage).toHaveBeenCalledOnce(); - result?.dispose(); - result?.dispose(); - } - expect(stop).toHaveBeenCalledOnce(); - expect(unsubscribe).toHaveBeenCalledOnce(); - expect(cancelFrame).toHaveBeenCalledWith(1); - expect(video.srcObject).toBeNull(); - }, - ); -}); diff --git a/apps/web/src/browser/recordingCompositor.ts b/apps/web/src/browser/recordingCompositor.ts index 8702b673a951..07e19c78d124 100644 --- a/apps/web/src/browser/recordingCompositor.ts +++ b/apps/web/src/browser/recordingCompositor.ts @@ -73,7 +73,7 @@ export async function createRecordingCompositor( } } -export class RecordingDecorations { +class RecordingDecorations { private ring: { x: number; y: number; diff --git a/apps/web/src/browser/serverPictureInPicture.test.ts b/apps/web/src/browser/serverPictureInPicture.test.ts deleted file mode 100644 index 629294a5f582..000000000000 --- a/apps/web/src/browser/serverPictureInPicture.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -import { applyPreviewServerSnapshot, resetPreviewStateForTests } from "~/previewStateStore"; -import { closeServerPictureInPicture, openServerPictureInPicture } from "./serverPictureInPicture"; - -vi.mock("~/state/previewStream", () => ({ - readPreviewStreamAccess: async () => ({}), -})); -vi.mock("@t3tools/client-runtime/preview/server-browser-stream", () => ({ - createPreviewFramePainter: () => ({ paint: vi.fn(), stop: vi.fn() }), - createPreviewStreamClient: () => ({ stop: vi.fn() }), -})); - -const ref = { - environmentId: EnvironmentId.make("environment"), - threadId: ThreadId.make("thread"), -}; -const stopTrack = vi.fn(); -const exitPictureInPicture = vi.fn(async () => {}); - -function navigate(url: string, tabId = "tab") { - applyPreviewServerSnapshot(ref, { - threadId: ref.threadId, - tabId, - navStatus: { _tag: "Success", url, title: "Document" }, - canGoBack: true, - canGoForward: false, - updatedAt: new Date().toISOString(), - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - resetPreviewStateForTests(); - const video = { - style: { cssText: "" }, - readyState: 4, - play: async () => {}, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - remove: vi.fn(), - requestPictureInPicture: vi.fn(async () => {}), - }; - vi.stubGlobal("HTMLMediaElement", { HAVE_METADATA: 1 }); - vi.stubGlobal("document", { - pictureInPictureEnabled: true, - pictureInPictureElement: video, - exitPictureInPicture, - body: { append: vi.fn() }, - createElement: (tag: string) => - tag === "video" - ? video - : { - getContext: () => ({ fillRect: vi.fn() }), - captureStream: () => ({ getTracks: () => [{ stop: stopTrack }] }), - }, - }); - navigate("https://environment.test/api/assets/token/index.html"); -}); - -afterEach(() => { - closeServerPictureInPicture(); - resetPreviewStateForTests(); - vi.unstubAllGlobals(); -}); - -it("closes the video window and stream when its tab becomes a client-rendered PDF", async () => { - await openServerPictureInPicture({ ...ref, tabId: "tab", seed: null }); - navigate("https://environment.test/api/assets/token/guide.PDF#page=2"); - expect(exitPictureInPicture).toHaveBeenCalledOnce(); - expect(stopTrack).toHaveBeenCalledOnce(); -}); - -it("keeps PiP for ordinary navigation and PDFs in another tab", async () => { - await openServerPictureInPicture({ ...ref, tabId: "tab", seed: null }); - navigate("https://environment.test/api/assets/token/guide.pdf", "another-tab"); - navigate("https://example.com/paper.pdf"); - navigate("https://environment.test/api/assets/token/next.html"); - expect(exitPictureInPicture).not.toHaveBeenCalled(); - expect(stopTrack).not.toHaveBeenCalled(); -}); - -it("stops observing navigation when a PiP session closes", async () => { - await openServerPictureInPicture({ ...ref, tabId: "tab", seed: null }); - closeServerPictureInPicture(); - navigate("https://environment.test/api/assets/token/guide.pdf"); - expect(exitPictureInPicture).toHaveBeenCalledOnce(); - expect(stopTrack).toHaveBeenCalledOnce(); -}); diff --git a/apps/web/src/browser/useBrowserLayoutZoomFactor.ts b/apps/web/src/browser/useBrowserLayoutZoomFactor.ts new file mode 100644 index 000000000000..a51be1685685 --- /dev/null +++ b/apps/web/src/browser/useBrowserLayoutZoomFactor.ts @@ -0,0 +1,15 @@ +import { useSyncExternalStore } from "react"; + +import { resolveBrowserLayoutZoomFactor } from "./browserViewportLayout"; + +// App zoom changes the CSS viewport, so resize also signals zoom changes. +const subscribeToWindowZoom = (onChange: () => void) => { + window.addEventListener("resize", onChange); + return () => window.removeEventListener("resize", onChange); +}; +const readWindowZoomFactor = () => window.desktopBridge?.getWindowZoomFactor?.() ?? 1; + +export function useBrowserLayoutZoomFactor(previewZoomFactor: number) { + const windowZoomFactor = useSyncExternalStore(subscribeToWindowZoom, readWindowZoomFactor); + return resolveBrowserLayoutZoomFactor(previewZoomFactor, windowZoomFactor); +} diff --git a/apps/web/src/browser/useBrowserViewportResize.ts b/apps/web/src/browser/useBrowserViewportResize.ts index 924e3730bd98..b3f3091ba1f1 100644 --- a/apps/web/src/browser/useBrowserViewportResize.ts +++ b/apps/web/src/browser/useBrowserViewportResize.ts @@ -1,5 +1,3 @@ -"use client"; - import type { PreviewViewportSetting, PreviewViewportSize } from "@t3tools/contracts"; import { useEffect, diff --git a/apps/web/src/browser/webviewCrashRecovery.test.ts b/apps/web/src/browser/webviewCrashRecovery.test.ts deleted file mode 100644 index a13e6ff06f6b..000000000000 --- a/apps/web/src/browser/webviewCrashRecovery.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, - planWebviewCrashRecovery, - WEBVIEW_CRASH_RECOVERY_WINDOW_MS, -} from "./webviewCrashRecovery"; - -describe("planWebviewCrashRecovery", () => { - it("backs off and stops after a bounded number of rapid crashes", () => { - const first = planWebviewCrashRecovery(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, 1_000); - expect(first).not.toBeNull(); - expect(first?.delayMs).toBe(250); - - const second = planWebviewCrashRecovery(first!.state, 1_100); - expect(second).not.toBeNull(); - expect(second?.delayMs).toBe(500); - - const third = planWebviewCrashRecovery(second!.state, 1_200); - expect(third).not.toBeNull(); - expect(third?.delayMs).toBe(1_000); - - expect(planWebviewCrashRecovery(third!.state, 1_300)).toBeNull(); - }); - - it("allows recovery again after the crash window expires", () => { - const first = planWebviewCrashRecovery(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, 1_000)!; - const second = planWebviewCrashRecovery(first.state, 1_100)!; - const third = planWebviewCrashRecovery(second.state, 1_200)!; - - expect(planWebviewCrashRecovery(third.state, 1_000 + WEBVIEW_CRASH_RECOVERY_WINDOW_MS)).toEqual( - { - delayMs: 250, - state: { - attempts: 1, - windowStartedAt: 1_000 + WEBVIEW_CRASH_RECOVERY_WINDOW_MS, - }, - }, - ); - }); -}); diff --git a/apps/web/src/browser/webviewCrashRecovery.ts b/apps/web/src/browser/webviewCrashRecovery.ts index 606244d43643..9c11278ad4c8 100644 --- a/apps/web/src/browser/webviewCrashRecovery.ts +++ b/apps/web/src/browser/webviewCrashRecovery.ts @@ -1,4 +1,4 @@ -export const WEBVIEW_CRASH_RECOVERY_WINDOW_MS = 30_000; +const WEBVIEW_CRASH_RECOVERY_WINDOW_MS = 30_000; const WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS = 3; const WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS = 250; diff --git a/apps/web/src/browserFaviconStore.test.ts b/apps/web/src/browserFaviconStore.test.ts index 1f2bc2bd7c0f..cfdedef00162 100644 --- a/apps/web/src/browserFaviconStore.test.ts +++ b/apps/web/src/browserFaviconStore.test.ts @@ -8,18 +8,12 @@ vi.mock("~/state/session", () => ({ usePreparedConnection: () => ({ _tag: "None" import { flushPendingFaviconsForThread, lookupFavicon, - mergeBrowserFaviconState, recordFaviconForProject, recordFaviconForThread, - registerFaviconProjectForThread, resetBrowserFaviconsForTests, resolveBrowserFaviconStorage, useBrowserFaviconStore, } from "./browserFaviconStore"; -import { - BROWSER_FAVICON_MAX_ENTRIES, - migratePersistedBrowserFaviconState, -} from "./browserFaviconLogic"; const environmentId = EnvironmentId.make("env-1"); const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1")); @@ -38,22 +32,6 @@ describe("browser favicon store", () => { vi.restoreAllMocks(); }); - it("keeps the newest capture for an origin and permits an identical later revisit", () => { - const recordFavicon = vi.spyOn(useBrowserFaviconStore.getState(), "recordFavicon"); - recordFaviconForProject(projectRef, favicon("http://localhost:3000/", 20), null); - recordFaviconForProject( - projectRef, - favicon("http://localhost:3000/old", 10, "data:image/png;base64,QkJCQg=="), - null, - ); - recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null); - recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null); - expect(Object.values(useBrowserFaviconStore.getState().byKey)).toEqual([ - { dataUrl: PNG, capturedAt: 30 }, - ]); - expect(recordFavicon).toHaveBeenCalledTimes(2); - }); - it("does not share localhost icons across environments or physical projects", () => { const otherEnvironment = scopeProjectRef( EnvironmentId.make("env-2"), @@ -89,68 +67,6 @@ describe("browser favicon store", () => { ).toBeNull(); }); - it("finds a persisted IPv6 environment icon without a live connection", () => { - recordFaviconForProject(projectRef, favicon("http://[fd00::1]:3000/app", 5), "fd00::1"); - const migrated = migratePersistedBrowserFaviconState({ - byKey: useBrowserFaviconStore.getState().byKey, - }).byKey; - expect(lookupFavicon(migrated, projectRef, "http://[fd00::1]:3000/app", null)).toBe(PNG); - }); - - it("keeps exact host aliases attached to the newest canonical icon", () => { - const olderPng = "data:image/png;base64,QkJCQg=="; - const newerPng = "data:image/png;base64,Q0NDQw=="; - recordFaviconForProject( - projectRef, - favicon("http://192.168.64.2:3000/", 10, olderPng), - "192.168.64.2", - ); - recordFaviconForProject( - projectRef, - favicon("http://192.168.64.3:3000/", 20, newerPng), - "192.168.64.3", - ); - recordFaviconForProject( - projectRef, - favicon("http://192.168.64.4:3000/", 15, olderPng), - "192.168.64.4", - ); - const byKey = useBrowserFaviconStore.getState().byKey; - for (const host of ["192.168.64.2", "192.168.64.3", "192.168.64.4"]) { - expect(lookupFavicon(byKey, projectRef, `http://${host}:3000/`, null)).toBe(newerPng); - } - expect(byKey["env-1:project-1 http://localhost:3000"]?.capturedAt).toBe(20); - }); - - it("evicts an icon and its exact host aliases atomically", () => { - recordFaviconForProject(projectRef, favicon("http://192.168.64.2:3000/", 100), "192.168.64.2"); - for (let index = 1; index < BROWSER_FAVICON_MAX_ENTRIES; index += 1) { - recordFaviconForProject( - projectRef, - favicon(`https://example-${index}.com/`, 100 + index), - null, - ); - } - expect( - lookupFavicon( - useBrowserFaviconStore.getState().byKey, - projectRef, - "http://192.168.64.2:3000/", - null, - ), - ).toBe(PNG); - - recordFaviconForProject(projectRef, favicon("https://evicts-oldest.example/", 1_000), null); - expect( - lookupFavicon( - useBrowserFaviconStore.getState().byKey, - projectRef, - "http://192.168.64.2:3000/", - null, - ), - ).toBeNull(); - }); - it("retains multiple origins until project and connection metadata hydrate", () => { expect( recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined), @@ -193,107 +109,6 @@ describe("browser favicon store", () => { expect(Object.values(useBrowserFaviconStore.getState().pendingByThreadKey)[0]).toBeDefined(); }); - it("keeps pending captures in store-owned state independent of bridge lifetime", () => { - recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 10), null, undefined); - const pendingAfterUnmount = useBrowserFaviconStore.getState().pendingByThreadKey; - useBrowserFaviconStore.setState({ pendingByThreadKey: pendingAfterUnmount }); - flushPendingFaviconsForThread(threadRef, projectRef, "localhost"); - expect(useBrowserFaviconStore.getState().byKey).toEqual({ - "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 10 }, - }); - }); - - it("flushes and resolves a pending draft-thread favicon after physical project registration", () => { - recordFaviconForThread(threadRef, favicon("http://localhost:8025/", 10), null, undefined); - registerFaviconProjectForThread(threadRef, projectRef); - const registered = useBrowserFaviconStore.getState().projectRefByThreadKey["env-1:thread-1"]; - expect(registered).toEqual(projectRef); - expect(flushPendingFaviconsForThread(threadRef, registered!, undefined)).toBe(true); - expect( - lookupFavicon( - useBrowserFaviconStore.getState().byKey, - registered!, - "http://localhost:8025/", - null, - ), - ).toBe(PNG); - }); - - it("bounds pending memory by origin and thread", () => { - for (let thread = 0; thread < 22; thread += 1) { - for (let port = 3000; port < 3012; port += 1) { - recordFaviconForThread( - { environmentId, threadId: ThreadId.make(`thread-${thread}`) }, - favicon(`http://localhost:${port}/`, port), - null, - undefined, - ); - } - } - const pending = useBrowserFaviconStore.getState().pendingByThreadKey; - expect(Object.keys(pending)).toHaveLength(20); - expect(Object.values(pending).every((byOrigin) => Object.keys(byOrigin).length === 10)).toBe( - true, - ); - }); - - it("sanitizes hydrated state while preserving actions and transient pending data", () => { - recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined); - const current = useBrowserFaviconStore.getState(); - const merged = mergeBrowserFaviconState( - { - byKey: { - "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 2 }, - "env-1:project-1 http://localhost:3001": { dataUrl: "bad", capturedAt: 3 }, - "env-1:project-1 http://localhost:3002": { dataUrl: PNG, capturedAt: 1e308 }, - ["x".repeat(5_000)]: { dataUrl: PNG, capturedAt: 4 }, - }, - }, - current, - ); - expect(merged.byKey).toEqual({ - "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 2 }, - }); - expect(merged.pendingByThreadKey).toEqual(current.pendingByThreadKey); - expect(typeof merged.recordFavicon).toBe("function"); - }); - - it("falls back to memory when localStorage access throws", () => { - vi.stubGlobal( - "window", - Object.defineProperty({}, "localStorage", { - get: () => { - throw new Error("storage blocked"); - }, - }), - ); - const storage = resolveBrowserFaviconStorage(); - storage.setItem("key", "value"); - expect(storage.getItem("key")).toBe("value"); - }); - - it("falls back to memory when localStorage operations throw", () => { - vi.stubGlobal("window", { - localStorage: { - getItem: vi.fn(() => { - throw new Error("read blocked"); - }), - setItem: vi.fn(() => { - throw new Error("quota exceeded"); - }), - removeItem: vi.fn(() => { - throw new Error("remove blocked"); - }), - }, - }); - const storage = resolveBrowserFaviconStorage(); - - storage.setItem("key", "value"); - expect(storage.getItem("key")).toBe("value"); - storage.removeItem("key"); - expect(storage.getItem("key")).toBeNull(); - }); - it("keeps the memory shadow authoritative after an asymmetric primary write failure", () => { vi.stubGlobal("window", { localStorage: { diff --git a/apps/web/src/browserFaviconStore.ts b/apps/web/src/browserFaviconStore.ts index 4051b4ebfbfb..c8d6fd521d24 100644 --- a/apps/web/src/browserFaviconStore.ts +++ b/apps/web/src/browserFaviconStore.ts @@ -162,7 +162,7 @@ export const useBrowserFaviconStore = create()( ), ); -export function mergeBrowserFaviconState( +function mergeBrowserFaviconState( persistedState: unknown, currentState: BrowserFaviconStoreState, ): BrowserFaviconStoreState { diff --git a/apps/web/src/browserHistoryStore.test.ts b/apps/web/src/browserHistoryStore.test.ts index cdabd8e80177..e06b35b3ccdb 100644 --- a/apps/web/src/browserHistoryStore.test.ts +++ b/apps/web/src/browserHistoryStore.test.ts @@ -9,16 +9,12 @@ const { readPreparedConnection } = vi.hoisted(() => ({ vi.mock("~/state/session", () => ({ readPreparedConnection })); import { - BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, BROWSER_HISTORY_MAX_PROJECTS, - BROWSER_HISTORY_MAX_TITLE_LENGTH, type BrowserHistoryEntry, - evictExcessProjects, mergeBrowserHistoryState, migratePersistedBrowserHistoryState, normalizeHistoryUrl, recordVisitForThread, - removeUrlForThread, resetBrowserHistoryForTests, setTitleForThreadUrl, upsertHistoryEntry, @@ -32,12 +28,6 @@ function entry(overrides: Partial = {}): BrowserHistoryEntr beforeEach(() => readPreparedConnection.mockReturnValue(null)); afterEach(() => vi.restoreAllMocks()); -function spyOnPersistWrites() { - const storage = useBrowserHistoryStore.persist.getOptions().storage; - if (!storage) throw new Error("Browser history persistence storage is unavailable."); - return vi.spyOn(storage, "setItem"); -} - describe("normalizeHistoryUrl", () => { it("normalizes bare loopback hosts to http and keeps path/query", () => { expect(normalizeHistoryUrl("localhost:3000/admin?tab=1")).toBe( @@ -45,10 +35,6 @@ describe("normalizeHistoryUrl", () => { ); }); - it("normalizes bare public hosts to https", () => { - expect(normalizeHistoryUrl("myapp.test")).toBe("https://myapp.test/"); - }); - it("preserves hash routes and strips credentials", () => { expect(normalizeHistoryUrl("http://localhost:3000/app#/route")).toBe( "http://localhost:3000/app#/route", @@ -64,43 +50,6 @@ describe("normalizeHistoryUrl", () => { }); describe("upsertHistoryEntry", () => { - it("prepends new urls", () => { - const next = upsertHistoryEntry([entry()], "http://localhost:5173/", 2000); - expect(next.map((e) => e.url)).toEqual(["http://localhost:5173/", "http://localhost:3000/"]); - expect(next[0]).toEqual({ url: "http://localhost:5173/", lastVisitedAt: 2000 }); - }); - - it("moves revisits to front, updates the timestamp, and keeps the title", () => { - const existing = [ - entry({ url: "http://a.test/", lastVisitedAt: 500, title: "A" }), - entry({ url: "http://b.test/", lastVisitedAt: 400 }), - ]; - const next = upsertHistoryEntry(existing, "http://b.test/", 3000); - expect(next.map((e) => e.url)).toEqual(["http://b.test/", "http://a.test/"]); - expect(next[0]?.lastVisitedAt).toBe(3000); - expect(next[1]?.title).toBe("A"); - }); - - it("caps the list at the per-project limit", () => { - const full = Array.from({ length: BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT }, (_, i) => - entry({ url: `http://localhost:${3000 + i}/`, lastVisitedAt: i }), - ); - const next = upsertHistoryEntry(full, "http://new.test/", 9999); - expect(next).toHaveLength(BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); - expect(next[0]?.url).toBe("http://new.test/"); - const lastPort = 3000 + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT - 1; - expect(next.some((e) => e.url === `http://localhost:${lastPort}/`)).toBe(false); - expect(next.some((e) => e.url === "http://localhost:3000/")).toBe(true); - }); - - it("with insertOrdered, slots an older entry below a newer one instead of prepending", () => { - const existing = [entry({ url: "http://newer.test/", lastVisitedAt: 2000 })]; - const next = upsertHistoryEntry(existing, "http://older.test/", 1000, { - insertOrdered: true, - }); - expect(next.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]); - }); - it("with insertOrdered, replaying an older visit for an existing entry keeps its newer timestamp", () => { const existing = [entry({ url: "http://a.test/", lastVisitedAt: 2000 })]; const next = upsertHistoryEntry(existing, "http://a.test/", 1000, { insertOrdered: true }); @@ -108,22 +57,6 @@ describe("upsertHistoryEntry", () => { }); }); -describe("evictExcessProjects", () => { - it("keeps the most recently visited projects when over the cap", () => { - const byProjectKey = Object.fromEntries( - Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 2 }, (_, i) => [ - `project-${i}`, - [entry({ lastVisitedAt: i })], - ]), - ); - const next = evictExcessProjects(byProjectKey); - expect(Object.keys(next)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS); - expect(next["project-0"]).toBeUndefined(); - expect(next["project-1"]).toBeUndefined(); - expect(next[`project-${BROWSER_HISTORY_MAX_PROJECTS + 1}`]).toBeDefined(); - }); -}); - describe("migratePersistedBrowserHistoryState", () => { it("drops malformed state and invalid entries", () => { expect(migratePersistedBrowserHistoryState(null)).toEqual({ byProjectKey: {} }); @@ -146,17 +79,6 @@ describe("migratePersistedBrowserHistoryState", () => { expect(migrated.byProjectKey["bad"]).toBeUndefined(); }); - it("normalizes persisted urls with the same rules as live writes", () => { - const migrated = migratePersistedBrowserHistoryState({ - byProjectKey: { - good: [{ url: "a.test/path#section", lastVisitedAt: 100 }], - }, - }); - expect(migrated.byProjectKey["good"]).toEqual([ - { url: "https://a.test/path#section", lastVisitedAt: 100 }, - ]); - }); - it("restores MRU ordering, deduplicates normalized urls, and enforces project bounds", () => { const byProjectKey = Object.fromEntries( Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 1 }, (_, index) => [ @@ -179,33 +101,6 @@ describe("migratePersistedBrowserHistoryState", () => { { url: "https://a.test/", lastVisitedAt: 2 }, ]); }); - - it("rejects a lastVisitedAt outside Date's valid range", () => { - const migrated = migratePersistedBrowserHistoryState({ - byProjectKey: { - good: [ - { url: "http://a.test/", lastVisitedAt: 100 }, - { url: "http://b.test/", lastVisitedAt: 1e20 }, - ], - }, - }); - expect(migrated.byProjectKey["good"]).toEqual([{ url: "http://a.test/", lastVisitedAt: 100 }]); - }); - - it("truncates oversized persisted titles to the contract bound", () => { - const oversized = "x".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 100); - const migrated = migratePersistedBrowserHistoryState({ - byProjectKey: { - good: [{ url: "http://a.test/", lastVisitedAt: 100, title: oversized }], - }, - }); - expect(migrated.byProjectKey["good"]?.[0]?.title).toHaveLength( - BROWSER_HISTORY_MAX_TITLE_LENGTH, - ); - expect(migrated.byProjectKey["good"]?.[0]?.title).toBe( - oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH), - ); - }); }); const threadRef = { @@ -218,30 +113,6 @@ describe("useBrowserHistoryStore", () => { resetBrowserHistoryForTests(); }); - it("records visits for registered threads under the project key", () => { - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - recordVisitForThread(threadRef, "myapp.test/admin#section", 1234); - expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ - { url: "https://myapp.test/admin#section", lastVisitedAt: 1234 }, - ]); - }); - - it("does not persist when a thread is already registered to the same project", () => { - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - const persist = spyOnPersistWrites(); - - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - - expect(persist).not.toHaveBeenCalled(); - }); - - it("ignores invalid urls whether queued pending or recorded post-registration", () => { - recordVisitForThread(threadRef, "ftp://a.test/", 1); - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - recordVisitForThread(threadRef, "ftp://a.test/", 2); - expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); - }); - it("sets titles update-only via the thread helper", () => { useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); setTitleForThreadUrl(threadRef, "http://a.test/", "Should not create"); @@ -251,19 +122,6 @@ describe("useBrowserHistoryStore", () => { expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("My App"); }); - it("does not persist when the title is already set", () => { - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - recordVisitForThread(threadRef, "http://a.test/", 1); - setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); - const persist = spyOnPersistWrites(); - const byProjectKey = useBrowserHistoryStore.getState().byProjectKey; - - setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); - - expect(useBrowserHistoryStore.getState().byProjectKey).toBe(byProjectKey); - expect(persist).not.toHaveBeenCalled(); - }); - it("sets a title against a settled url that differs from the stored one only by a trailing slash", () => { useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); recordVisitForThread(threadRef, "http://a.test/community", 1); @@ -306,44 +164,6 @@ describe("useBrowserHistoryStore", () => { { url: "http://localhost:5173/app", lastVisitedAt: 5 }, ]); }); - - it("does not match a genuinely different path via the trailing-slash comparison", () => { - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - recordVisitForThread(threadRef, "http://a.test/community", 1); - setTitleForThreadUrl(threadRef, "http://a.test/community/foo", "Foo"); - expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBeUndefined(); - }); - - it("updates only the most recent entry when several share a trailing-slash comparison key", () => { - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - recordVisitForThread(threadRef, "http://a.test/community/", 1); - recordVisitForThread(threadRef, "http://a.test/community", 2); - setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community"); - const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; - expect(entries?.[0]).toMatchObject({ url: "http://a.test/community", title: "Community" }); - expect(entries?.[1]).toMatchObject({ url: "http://a.test/community/" }); - expect(entries?.[1]?.title).toBeUndefined(); - }); - - it("truncates oversized titles to the contract bound", () => { - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - recordVisitForThread(threadRef, "http://a.test/", 1); - const oversized = "y".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 50); - setTitleForThreadUrl(threadRef, "http://a.test/", oversized); - const title = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title; - expect(title).toHaveLength(BROWSER_HISTORY_MAX_TITLE_LENGTH); - expect(title).toBe(oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH)); - }); - - it("removes entries", () => { - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - recordVisitForThread(threadRef, "http://a.test/", 1); - recordVisitForThread(threadRef, "http://b.test/", 2); - removeUrlForThread(threadRef, "http://a.test/"); - expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([ - "http://b.test/", - ]); - }); }); describe("pendingVisitsByThreadKey", () => { @@ -365,18 +185,6 @@ describe("pendingVisitsByThreadKey", () => { expect(useBrowserHistoryStore.getState().pendingVisitsByThreadKey).toEqual({}); }); - it("caps the per-thread pending list at 10, dropping the oldest", () => { - for (let i = 0; i < 12; i++) { - recordVisitForThread(threadRef, `http://a.test/${i}`, i); - } - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - const urls = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url); - expect(urls).toHaveLength(10); - expect(urls).not.toContain("http://a.test/0"); - expect(urls).not.toContain("http://a.test/1"); - expect(urls?.[0]).toBe("http://a.test/11"); - }); - it("slots a replayed visit by timestamp instead of hoisting it above a newer live visit", () => { const otherThreadRef = { environmentId: EnvironmentId.make("env-1"), @@ -409,13 +217,6 @@ describe("pendingTitlesByThreadKey", () => { expect(entries?.[0]).toMatchObject({ url: "http://a.test/", title: "My App" }); expect(useBrowserHistoryStore.getState().pendingTitlesByThreadKey).toEqual({}); }); - - it("preserves environment host matching while a title is pending", () => { - recordVisitForThread(threadRef, "http://localhost:5173/app", 1); - setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2"); - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App"); - }); }); describe("mergeBrowserHistoryState", () => { diff --git a/apps/web/src/bundledDev.test.ts b/apps/web/src/bundledDev.test.ts index 0b11c52a5e3d..770b26e0a686 100644 --- a/apps/web/src/bundledDev.test.ts +++ b/apps/web/src/bundledDev.test.ts @@ -1,6 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - builds and executes real dev bundles on disk. import * as NodeChildProcess from "node:child_process"; -import * as NodeEvents from "node:events"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -8,11 +7,9 @@ import * as NodeURL from "node:url"; import * as NodeUtil from "node:util"; import react from "@vitejs/plugin-react"; -import { createLogger, createServer } from "vite-plus"; +import { createServer } from "vite-plus"; import { expect, it } from "vite-plus/test"; -import { tailwindPlugins } from "../vite/tailwind"; - const execFile = NodeUtil.promisify(NodeChildProcess.execFile); it("initializes React refresh before a shared UI chunk runs in bundled dev", async () => { @@ -121,104 +118,3 @@ console.log("App started with React refresh ready.");`, await NodeFSP.rm(root, { recursive: true, force: true }); } }); - -it("hot updates Tailwind classes when a source file changes in bundled dev", async () => { - const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tailwind-")); - const events = new NodeEvents.EventEmitter(); - let server: Awaited> | undefined; - let socket: WebSocket | undefined; - let css = ""; - - try { - await NodeFSP.writeFile( - NodePath.join(root, "index.html"), - '', - ); - const source = - 'import "./style.css"; export const margin = "m-[13px]"; import.meta.hot?.accept();'; - await NodeFSP.writeFile(NodePath.join(root, "main.ts"), source); - await NodeFSP.writeFile( - NodePath.join(root, "style.css"), - '@import "tailwindcss" source(none); @source "./main.ts";', - ); - const logger = createLogger("silent"); - logger.error = (message) => events.emit("error", new Error(message)); - const connected = NodeEvents.EventEmitter.once(events, "connected"); - const ready = NodeEvents.EventEmitter.once(events, "ready"); - server = await createServer({ - configFile: false, - root, - customLogger: logger, - resolve: { - alias: { - tailwindcss: NodeURL.fileURLToPath( - new URL("../node_modules/tailwindcss/index.css", import.meta.url), - ), - }, - }, - experimental: { bundledDev: true }, - plugins: [ - ...tailwindPlugins(true), - { - name: "observe-tailwind-output", - enforce: "pre", - transform(code, id) { - if (id.endsWith("/style.css")) css = code; - }, - async generateBundle() { - // Keep the build pending until the socket can receive Vite's ready message. - await connected; - }, - }, - ], - server: { host: "127.0.0.1", port: 0 }, - }); - await server.listen(); - const address = server.httpServer?.address(); - if (!address || typeof address === "string") throw new Error("Vite did not bind a port"); - - server.ws.on("vite:client-connected", () => events.emit("connected")); - socket = new WebSocket( - `ws://127.0.0.1:${address.port}/?token=${server.config.webSocketToken}`, - "vite-hmr", - ); - socket.addEventListener("open", () => { - socket?.send( - JSON.stringify({ - type: "custom", - event: "vite:client-connected", - data: { clientId: "tailwind-test" }, - }), - ); - }); - socket.addEventListener("message", ({ data }) => { - const message: unknown = JSON.parse(String(data)); - if (message !== null && typeof message === "object" && "type" in message) { - // generateBundle runs before Vite stores the files for HTTP requests. - if ( - message.type === "full-reload" && - "ifFallback" in message && - message.ifFallback === true - ) { - events.emit("ready"); - } else if (message.type === "bundled-dev-update") { - events.emit("updated"); - } - } - }); - await connected; - await ready; - const entry = await fetch(`http://127.0.0.1:${address.port}/assets/index.js`); - expect(entry.headers.get("content-type")).toContain("javascript"); - await entry.text(); - - const updated = NodeEvents.EventEmitter.once(events, "updated"); - await NodeFSP.writeFile(NodePath.join(root, "main.ts"), source.replace("13px", "137px")); - await updated; - expect(css).toContain("margin: 137px"); - } finally { - socket?.close(); - await server?.close(); - await NodeFSP.rm(root, { recursive: true, force: true }); - } -}); diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index 5154fe47e035..fd3821b81b53 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -38,20 +38,6 @@ afterEach(() => { }); describe("clientPersistenceStorage", () => { - it("persists client settings in browser storage", async () => { - getTestWindow(); - const { readBrowserClientSettings, writeBrowserClientSettings } = - await import("./clientPersistenceStorage"); - const settings = { - ...DEFAULT_CLIENT_SETTINGS, - timestampFormat: "24-hour" as const, - }; - - writeBrowserClientSettings(settings); - - expect(readBrowserClientSettings()).toEqual(settings); - }); - it.each(["not-json", '{"wordWrap":"invalid"}'])( "does not treat invalid saved settings as absent: %s", async (value) => { @@ -92,53 +78,4 @@ describe("clientPersistenceStorage", () => { expect(readBrowserClientSettings()).toEqual(settings); expect(write).not.toHaveBeenCalled(); }); - - it("defaults word wrap on and discards obsolete wrapping preferences", async () => { - const testWindow = getTestWindow(); - testWindow.localStorage.setItem( - "t3code:client-settings:v1", - JSON.stringify({ - chatWordWrap: false, - diffWordWrap: false, - }), - ); - const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); - const settings = readBrowserClientSettings(); - - expect(settings).toEqual( - expect.objectContaining({ - wordWrap: true, - }), - ); - expect(settings).not.toHaveProperty("chatWordWrap"); - expect(settings).not.toHaveProperty("diffWordWrap"); - }); - - it("keeps the default diff file state across reloads and defaults it to collapsed", async () => { - const testWindow = getTestWindow(); - const { readBrowserClientSettings, writeBrowserClientSettings } = - await import("./clientPersistenceStorage"); - - testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify({})); - expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(true); - - writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: true }); - expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(true); - - writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: false }); - expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(false); - }); - - it("keeps the diff layout across reloads and defaults it to stacked", async () => { - const testWindow = getTestWindow(); - const { readBrowserClientSettings, writeBrowserClientSettings } = - await import("./clientPersistenceStorage"); - - expect(readBrowserClientSettings()).toBeNull(); - testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify({})); - expect(readBrowserClientSettings()?.diffLayout).toBe("stacked"); - - writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffLayout: "split" }); - expect(readBrowserClientSettings()?.diffLayout).toBe("split"); - }); }); diff --git a/apps/web/src/closedViewStore.test.ts b/apps/web/src/closedViewStore.test.ts index 6bb4631bf572..0223dfb9d5bd 100644 --- a/apps/web/src/closedViewStore.test.ts +++ b/apps/web/src/closedViewStore.test.ts @@ -29,22 +29,6 @@ const snapshot: PreviewSessionSnapshot = { }; describe("closedViewStore", () => { - it.each([null, {}, { entries: null }, { entries: {} }, { entries: "invalid" }])( - "rehydrates malformed older history %j as empty history", - async (state) => { - useClosedViewStore.getState().remember(diff(refA)); - const { storage, name } = useClosedViewStore.persist.getOptions(); - await storage!.setItem(name!, { - state: state as { entries: ClosedViewEntry[] }, - version: 0, - }); - await useClosedViewStore.persist.rehydrate(); - expect(useClosedViewStore.persist.hasHydrated()).toBe(true); - expect(useClosedViewStore.getState().entries).toEqual([]); - expect(await storage!.getItem(name!)).toEqual({ state: { entries: [] }, version: 2 }); - }, - ); - it.each([ { id: "bad", kind: "browser", threadRef: refA, snapshot: {} }, { @@ -83,59 +67,6 @@ describe("closedViewStore", () => { expect(() => useClosedViewStore.getState().remember(diff(refB))).not.toThrow(); }); - it("preserves every supported saved panel type and its restore data", async () => { - const surfaces = [ - { kind: "diff", id: "diff" }, - { kind: "files", id: "files" }, - { kind: "pull-requests", id: "pull-requests" }, - { kind: "preview", id: "browser:new", resourceId: null }, - { kind: "preview", id: "browser:saved", resourceId: "saved" }, - { kind: "device", id: "device" }, - { - kind: "device", - id: "device:phone", - title: "My phone", - target: { hostId: "mac", deviceId: "phone", platform: "ios", name: "iPhone" }, - }, - { kind: "file", id: "file:x", relativePath: "x", revealLine: 12, revealRequestId: 1 }, - { - kind: "file", - id: "attachment:a", - relativePath: "report.pdf", - revealLine: null, - revealRequestId: 0, - attachment: { - type: "file", - id: "a", - name: "report.pdf", - mimeType: "application/pdf", - sizeBytes: 1, - }, - }, - { - kind: "pull-request", - id: "pull-request:x", - projectId: "project", - repository: "owner/repo", - number: 1, - environmentId: "env-1", - host: "github.com", - url: "https://github.com/owner/repo/pull/1", - }, - ]; - const entries = surfaces.map((surface, index) => ({ - id: String(index), - kind: "panel-tab", - threadRef: refA, - surface, - })) as ClosedViewEntry[]; - const { storage, name } = useClosedViewStore.persist.getOptions(); - await storage!.setItem(name!, { state: { entries }, version: 0 }); - await useClosedViewStore.persist.rehydrate(); - expect(useClosedViewStore.persist.hasHydrated()).toBe(true); - expect(useClosedViewStore.getState().entries).toEqual(entries); - }); - it("keeps private tabs available in memory without saving their metadata", async () => { const store = useClosedViewStore.getState(); const publicId = store.remember(diff(refA)); @@ -189,32 +120,4 @@ describe("closedViewStore", () => { expect(saved).not.toContain("private.example"); expect(saved).not.toContain("Private page"); }); - - it("keeps newest first, moves a re-closed tab to the front, and caps at 20", () => { - const store = useClosedViewStore.getState(); - store.remember(diff(refA)); - store.remember(diff(refB)); - store.remember(diff(refA)); - expect(useClosedViewStore.getState().entries).toMatchObject([ - { threadRef: refA }, - { threadRef: refB }, - ]); - - for (let index = 0; index < 24; index++) { - store.remember({ - kind: "panel-tab", - threadRef: refA, - surface: { - kind: "file", - id: `file:${index}`, - relativePath: `${index}.ts`, - revealLine: null, - revealRequestId: 0, - }, - }); - } - const entries = useClosedViewStore.getState().entries; - expect(entries).toHaveLength(20); - expect(entries[0]).toMatchObject({ surface: { id: "file:23" } }); - }); }); diff --git a/apps/web/src/cloud/connectCliAuth.test.ts b/apps/web/src/cloud/connectCliAuth.test.ts index 63bee2d9ccfa..97db1089c8e8 100644 --- a/apps/web/src/cloud/connectCliAuth.test.ts +++ b/apps/web/src/cloud/connectCliAuth.test.ts @@ -1,10 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { - buildConnectCliClerkAuthorizeUrl, - connectCliSignInRedirectUrl, - hasConnectCliAuthConfig, -} from "./connectCliAuth"; +import { buildConnectCliClerkAuthorizeUrl, connectCliSignInRedirectUrl } from "./connectCliAuth"; const TEST_PUBLISHABLE_KEY = `pk_test_${btoa("witty-mole-42.clerk.accounts.dev$")}`; @@ -13,16 +9,6 @@ describe("connectCliAuth", () => { vi.unstubAllEnvs(); }); - it("requires both the publishable key and the CLI OAuth client id", () => { - vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); - vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay"); - vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.com"); - expect(hasConnectCliAuthConfig()).toBe(false); - - vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); - expect(hasConnectCliAuthConfig()).toBe(true); - }); - it("builds a PKCE authorize URL that redirects to the CLI's loopback listener", () => { vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); @@ -43,17 +29,6 @@ describe("connectCliAuth", () => { expect(url.searchParams.get("code_challenge_method")).toBe("S256"); }); - it("returns null when the CLI OAuth client id is not configured", () => { - vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); - expect( - buildConnectCliClerkAuthorizeUrl({ - state: "state-1", - challenge: "challenge-1", - loopbackPort: 34338, - }), - ).toBeNull(); - }); - it("sends the sign-in redirect to the authorize endpoint, not back to /connect", () => { vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); @@ -68,17 +43,4 @@ describe("connectCliAuth", () => { expect(redirectUrl).not.toBe(connectUrl); expect(new URL(redirectUrl).pathname).toBe("/oauth/authorize"); }); - - it("falls back to the current URL when the authorize URL cannot be built", () => { - vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); - - const connectUrl = - "https://app.t3.codes/connect#state=state-1&challenge=challenge-1&port=34338"; - expect( - connectCliSignInRedirectUrl( - { state: "state-1", challenge: "challenge-1", loopbackPort: 34338 }, - connectUrl, - ), - ).toBe(connectUrl); - }); }); diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts index 5510b698216f..25a5a21400a3 100644 --- a/apps/web/src/cloud/connectCliAuth.ts +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -13,7 +13,7 @@ function resolveConnectCliOAuthClientId(): string | null { return trimNonEmpty(import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID as string | undefined); } -export function hasConnectCliAuthConfig(): boolean { +function hasConnectCliAuthConfig(): boolean { return Boolean( resolveCloudPublicConfig().clerkPublishableKey && resolveConnectCliOAuthClientId(), ); diff --git a/apps/web/src/cloud/dpop.ts b/apps/web/src/cloud/dpop.ts index 2569108563b3..97da6d4ca8a5 100644 --- a/apps/web/src/cloud/dpop.ts +++ b/apps/web/src/cloud/dpop.ts @@ -142,44 +142,42 @@ export const generateBrowserDpopKey = Effect.gen(function* () { }; }); -export function createBrowserDpopProof(input: { +export const createBrowserDpopProof = Effect.fn("dpop.createBrowserDpopProof")(function* (input: { readonly method: string; readonly url: string; readonly accessToken?: string; readonly proofKey: BrowserDpopKey; -}): Effect.Effect< +}): Effect.fn.Return< { readonly proof: string; readonly thumbprint: string }, BrowserDpopError, Crypto.Crypto > { - return Effect.gen(function* () { - const normalizedUrl = yield* Effect.try({ - try: () => new URL(input.url), - catch: (cause) => dpopError("Could not normalize DPoP proof URL.", cause), - }); - normalizedUrl.search = ""; - normalizedUrl.hash = ""; - const jti = yield* Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4()), - Effect.mapError((cause) => dpopError("Could not generate DPoP proof identifier.", cause)), - ); - const proof = yield* Effect.tryPromise({ - try: () => - new SignJWT({ - htm: input.method.toUpperCase(), - htu: normalizedUrl.toString(), - jti, - ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), + const normalizedUrl = yield* Effect.try({ + try: () => new URL(input.url), + catch: (cause) => dpopError("Could not normalize DPoP proof URL.", cause), + }); + normalizedUrl.search = ""; + normalizedUrl.hash = ""; + const jti = yield* Crypto.Crypto.pipe( + Effect.flatMap((crypto) => crypto.randomUUIDv4()), + Effect.mapError((cause) => dpopError("Could not generate DPoP proof identifier.", cause)), + ); + const proof = yield* Effect.tryPromise({ + try: () => + new SignJWT({ + htm: input.method.toUpperCase(), + htu: normalizedUrl.toString(), + jti, + ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), + }) + .setProtectedHeader({ + typ: "dpop+jwt", + alg: "ES256", + jwk: input.proofKey.publicJwk, }) - .setProtectedHeader({ - typ: "dpop+jwt", - alg: "ES256", - jwk: input.proofKey.publicJwk, - }) - .setIssuedAt() - .sign(input.proofKey.privateKey), - catch: (cause) => dpopError("Could not sign DPoP proof.", cause), - }); - return { proof, thumbprint: input.proofKey.thumbprint }; + .setIssuedAt() + .sign(input.proofKey.privateKey), + catch: (cause) => dpopError("Could not sign DPoP proof.", cause), }); -} + return { proof, thumbprint: input.proofKey.thumbprint }; +}); diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index dbcaacfa25a4..65cf9402e5fc 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -25,13 +25,7 @@ import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth"; -import { - linkPrimaryEnvironmentToCloud, - readPrimaryCloudLinkState, - type CloudLinkTarget, - unlinkPrimaryEnvironmentFromCloud, - updatePrimaryCloudPreferences, -} from "./linkEnvironment"; +import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment"; const TARGET: CloudLinkTarget = { environmentId: "environment-1", @@ -136,10 +130,6 @@ function withServices( return effect.pipe(Effect.provide(layerServices(options))); } -function bodyText(body: BodyInit | null | undefined): string { - return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? ""); -} - beforeEach(() => { vi.clearAllMocks(); vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); @@ -154,38 +144,6 @@ afterEach(() => { }); describe("web cloud link environment client", () => { - it.effect("reads primary cloud link state from the explicit target", () => - Effect.gen(function* () { - const fetchMock = vi.fn().mockResolvedValue( - Response.json({ - linked: true, - cloudUserId: "user-1", - relayUrl: "https://relay.example.test", - relayIssuer: "https://relay.example.test", - managedTunnelActive: true, - publishAgentActivity: false, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const state = yield* withServices(readPrimaryCloudLinkState({ target: TARGET })); - - expect(Option.fromNullishOr(state)).toEqual( - Option.some({ - linked: true, - cloudUserId: "user-1", - relayUrl: "https://relay.example.test", - relayIssuer: "https://relay.example.test", - managedTunnelActive: true, - publishAgentActivity: false, - }), - ); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "http://127.0.0.1:3000/api/connect/link-state", - ); - }), - ); - it.effect("uses desktop bearer auth for primary cloud link state", () => Effect.gen(function* () { const fetchMock = vi.fn().mockResolvedValue( @@ -213,181 +171,4 @@ describe("web cloud link environment client", () => { expect(request.headers.get("authorization")).toBe("Bearer desktop-bearer-token"); }), ); - - it.effect("updates agent activity publishing for the explicit primary target", () => - Effect.gen(function* () { - const fetchMock = vi.fn().mockResolvedValue( - Response.json({ - linked: true, - cloudUserId: "user-1", - relayUrl: "https://relay.example.test", - relayIssuer: "https://relay.example.test", - managedTunnelActive: true, - publishAgentActivity: true, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const state = yield* withServices( - updatePrimaryCloudPreferences({ - target: TARGET, - publishAgentActivity: true, - }), - ); - - expect(state.publishAgentActivity).toBe(true); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "http://127.0.0.1:3000/api/connect/preferences", - ); - expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); - expect(JSON.parse(bodyText(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ - publishAgentActivity: true, - }); - }), - ); - - it.effect("links an available primary environment without invoking installation", () => - Effect.gen(function* () { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - challenge: "challenge", - expiresAt: "2026-06-06T00:05:00.000Z", - }), - ) - .mockResolvedValueOnce(Response.json("signed-proof")) - .mockResolvedValueOnce( - Response.json({ - ok: true, - environmentId: TARGET.environmentId, - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "cloudflare_tunnel", - }, - endpointRuntime: null, - relayIssuer: "https://relay.example.test", - cloudUserId: "user-1", - environmentCredential: "environment-credential", - cloudMintPublicKey: "public-key", - }), - ) - .mockResolvedValueOnce( - Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), - ); - vi.stubGlobal("fetch", fetchMock); - - yield* withServices( - linkPrimaryEnvironmentToCloud({ - target: TARGET, - clerkToken: "clerk-token", - }), - ); - - expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled(); - expect(String(fetchMock.mock.calls[1]?.[0])).toBe( - "http://127.0.0.1:3000/api/connect/link-proof", - ); - expect(JSON.parse(bodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ - challenge: "challenge", - endpoint: { - httpBaseUrl: TARGET.httpBaseUrl, - wsBaseUrl: TARGET.wsBaseUrl, - }, - }); - }), - ); - - it.effect("links publish-only without a managed tunnel", () => - Effect.gen(function* () { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - challenge: "challenge", - expiresAt: "2026-06-06T00:05:00.000Z", - }), - ) - .mockResolvedValueOnce(Response.json("signed-proof")) - .mockResolvedValueOnce( - Response.json({ - ok: true, - environmentId: TARGET.environmentId, - endpoint: { - httpBaseUrl: TARGET.httpBaseUrl, - wsBaseUrl: TARGET.wsBaseUrl, - providerKind: "manual", - }, - endpointRuntime: null, - relayIssuer: "https://relay.example.test", - cloudUserId: "user-1", - environmentCredential: "environment-credential", - cloudMintPublicKey: "public-key", - }), - ) - .mockResolvedValueOnce( - Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), - ); - vi.stubGlobal("fetch", fetchMock); - - yield* withServices( - linkPrimaryEnvironmentToCloud({ - target: TARGET, - clerkToken: "clerk-token", - mode: "publish_only", - }), - ); - - expect(JSON.parse(bodyText(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ - managedTunnelsEnabled: false, - }); - expect(JSON.parse(bodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ - endpoint: { providerKind: "manual" }, - }); - }), - ); - - it.effect("installs a missing relay client before linking", () => - Effect.gen(function* () { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ malformed: true }))); - - yield* withServices( - linkPrimaryEnvironmentToCloud({ - target: TARGET, - clerkToken: "clerk-token", - }), - { - status: { status: "available", version: "2026.6.0" }, - installEvents: [], - }, - ).pipe(Effect.flip); - - expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled(); - }), - ); - - it.effect("unlinks locally before revoking the relay record", () => - Effect.gen(function* () { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), - ) - .mockResolvedValueOnce(Response.json({ ok: true })); - vi.stubGlobal("fetch", fetchMock); - - yield* withServices( - unlinkPrimaryEnvironmentFromCloud({ - target: TARGET, - clerkToken: "clerk-token", - }), - ); - - expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink"); - expect(String(fetchMock.mock.calls[1]?.[0])).toContain( - `/v1/client/environment-links/${TARGET.environmentId}`, - ); - }), - ); }); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index d7a4c3fe8ce4..e6e42dc9218b 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -48,10 +48,10 @@ const relayClientRpcError = (message: string) => (cause: unknown) => cause, }); -function ensureRelayClientAvailable( - environmentId: EnvironmentId, -): Effect.Effect { - return Effect.gen(function* () { +const ensureRelayClientAvailable = Effect.fn("linkEnvironment.ensureRelayClientAvailable")( + function* ( + environmentId: EnvironmentId, + ): Effect.fn.Return { const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const status = yield* registry .run(environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {})) @@ -99,8 +99,8 @@ function ensureRelayClientAvailable( : "The relay client is still unavailable after installation.", }); } - }); -} + }, +); const isEnvironmentCloudApiError = Schema.is( Schema.Union([ @@ -184,163 +184,169 @@ export interface CloudLinkTarget { export type CloudLinkState = EnvironmentCloudLinkStateResult; -export function readPrimaryCloudLinkState(input: { +export const readPrimaryCloudLinkState: (input: { readonly target: CloudLinkTarget; -}): Effect.Effect { - return Effect.gen(function* () { +}) => Effect.Effect = + Effect.fn("linkEnvironment.readPrimaryCloudLinkState")(function* (input: { + readonly target: CloudLinkTarget; + }) { const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); return yield* client.connect .linkState({ headers: {} }) .pipe(Effect.mapError(environmentApiError("Could not read environment cloud link state."))); - }).pipe(Effect.provide(PrimaryEnvironmentHttpLayer.layer)); -} + }, Effect.provide(PrimaryEnvironmentHttpLayer.layer)); -export function updatePrimaryCloudPreferences(input: { +export const updatePrimaryCloudPreferences: (input: { readonly target: CloudLinkTarget; readonly publishAgentActivity: boolean; readonly holdWebhooksWhileOffline?: boolean; -}): Effect.Effect { - return Effect.gen(function* () { - const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); - const { target: _target, ...payload } = input; - return yield* client.connect - .preferences({ - headers: {}, - payload, - }) - .pipe( - Effect.mapError(environmentApiError("Could not update environment cloud preferences.")), - ); - }).pipe(Effect.provide(PrimaryEnvironmentHttpLayer.layer)); -} +}) => Effect.Effect = Effect.fn( + "linkEnvironment.updatePrimaryCloudPreferences", +)(function* (input: { + readonly target: CloudLinkTarget; + readonly publishAgentActivity: boolean; + readonly holdWebhooksWhileOffline?: boolean; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); + const { target: _target, ...payload } = input; + return yield* client.connect + .preferences({ + headers: {}, + payload, + }) + .pipe(Effect.mapError(environmentApiError("Could not update environment cloud preferences."))); +}, Effect.provide(PrimaryEnvironmentHttpLayer.layer)); -export function unlinkPrimaryEnvironmentFromCloud(input: { +export const unlinkPrimaryEnvironmentFromCloud: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string | null; -}): Effect.Effect< +}) => Effect.Effect< void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelay.ManagedRelayClient -> { - return Effect.gen(function* () { - const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); - yield* client.connect - .unlink({ headers: {} }) - .pipe(Effect.mapError(environmentApiError("Could not unlink the environment from cloud."))); +> = Effect.fn("linkEnvironment.unlinkPrimaryEnvironmentFromCloud")(function* (input: { + readonly target: CloudLinkTarget; + readonly clerkToken: string | null; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); + yield* client.connect + .unlink({ headers: {} }) + .pipe(Effect.mapError(environmentApiError("Could not unlink the environment from cloud."))); - const configuredRelayUrl = relayUrl(); - if (configuredRelayUrl && input.clerkToken) { - const relayClient = yield* ManagedRelay.ManagedRelayClient; - yield* relayClient - .unlinkEnvironment({ - clerkToken: input.clerkToken, - environmentId: EnvironmentId.make(input.target.environmentId), - }) - .pipe( - Effect.catch((cause) => - Effect.logWarning("Could not revoke cloud environment link after local unlink.", { - cause, - }), - ), - ); - } - }).pipe(Effect.provide(PrimaryEnvironmentHttpLayer.layer)); -} + const configuredRelayUrl = relayUrl(); + if (configuredRelayUrl && input.clerkToken) { + const relayClient = yield* ManagedRelay.ManagedRelayClient; + yield* relayClient + .unlinkEnvironment({ + clerkToken: input.clerkToken, + environmentId: EnvironmentId.make(input.target.environmentId), + }) + .pipe( + Effect.catch((cause) => + Effect.logWarning("Could not revoke cloud environment link after local unlink.", { + cause, + }), + ), + ); + } +}, Effect.provide(PrimaryEnvironmentHttpLayer.layer)); export type CloudLinkMode = "managed" | "publish_only"; const PUBLISH_ONLY_PROVIDER_KIND = "manual" satisfies RelayManagedEndpointProviderKind; -export function linkPrimaryEnvironmentToCloud(input: { +export const linkPrimaryEnvironmentToCloud: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string; readonly mode?: CloudLinkMode; -}): Effect.Effect< +}) => Effect.Effect< void, CloudEnvironmentLinkError, EnvironmentRegistry.EnvironmentRegistry | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient -> { - return Effect.gen(function* () { - const configuredRelayUrl = relayUrl(); - if (!configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); - } - const managedTunnelsEnabled = (input.mode ?? "managed") === "managed"; - const providerKind = managedTunnelsEnabled - ? MANAGED_ENDPOINT_PROVIDER_KIND - : PUBLISH_ONLY_PROVIDER_KIND; - const relayClient = yield* ManagedRelay.ManagedRelayClient; - const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); - if (managedTunnelsEnabled) { - yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId)); - } +> = Effect.fn("linkEnvironment.linkPrimaryEnvironmentToCloud")(function* (input: { + readonly target: CloudLinkTarget; + readonly clerkToken: string; + readonly mode?: CloudLinkMode; +}) { + const configuredRelayUrl = relayUrl(); + if (!configuredRelayUrl) { + return yield* new CloudEnvironmentLinkError({ + message: "T3CODE_RELAY_URL is not configured.", + }); + } + const managedTunnelsEnabled = (input.mode ?? "managed") === "managed"; + const providerKind = managedTunnelsEnabled + ? MANAGED_ENDPOINT_PROVIDER_KIND + : PUBLISH_ONLY_PROVIDER_KIND; + const relayClient = yield* ManagedRelay.ManagedRelayClient; + const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); + if (managedTunnelsEnabled) { + yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId)); + } - const challenge = yield* relayClient - .createEnvironmentLinkChallenge({ - clerkToken: input.clerkToken, - payload: { - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled, - }, - }) - .pipe( - Effect.mapError( - decodedRelayClientError( - `${configuredRelayUrl}/v1/client/environment-link-challenges failed`, - ), + const challenge = yield* relayClient + .createEnvironmentLinkChallenge({ + clerkToken: input.clerkToken, + payload: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled, + }, + }) + .pipe( + Effect.mapError( + decodedRelayClientError( + `${configuredRelayUrl}/v1/client/environment-link-challenges failed`, ), - ); - const proof = yield* environmentClient.connect - .linkProof({ - headers: {}, - payload: { - challenge: challenge.challenge, - relayIssuer: configuredRelayUrl, - endpoint: { - httpBaseUrl: input.target.httpBaseUrl, - wsBaseUrl: input.target.wsBaseUrl, - providerKind, - }, - origin: endpointOrigin(input.target.httpBaseUrl), - }, - }) - .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof."))); - const link = yield* relayClient - .linkEnvironment({ - clerkToken: input.clerkToken, - payload: { - proof, - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled, + ), + ); + const proof = yield* environmentClient.connect + .linkProof({ + headers: {}, + payload: { + challenge: challenge.challenge, + relayIssuer: configuredRelayUrl, + endpoint: { + httpBaseUrl: input.target.httpBaseUrl, + wsBaseUrl: input.target.wsBaseUrl, + providerKind, }, - }) - .pipe( - Effect.mapError( - decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`), - ), - ); - yield* ensureLinkedEnvironmentMatches({ - expectedEnvironmentId: input.target.environmentId, - expectedProviderKind: providerKind, - link, - }); + origin: endpointOrigin(input.target.httpBaseUrl), + }, + }) + .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof."))); + const link = yield* relayClient + .linkEnvironment({ + clerkToken: input.clerkToken, + payload: { + proof, + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled, + }, + }) + .pipe( + Effect.mapError( + decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`), + ), + ); + yield* ensureLinkedEnvironmentMatches({ + expectedEnvironmentId: input.target.environmentId, + expectedProviderKind: providerKind, + link, + }); - yield* environmentClient.connect - .relayConfig({ - headers: {}, - payload: { - relayUrl: configuredRelayUrl, - relayIssuer: link.relayIssuer, - cloudUserId: link.cloudUserId, - environmentCredential: link.environmentCredential, - cloudMintPublicKey: link.cloudMintPublicKey, - endpointRuntime: link.endpointRuntime, - }, - }) - .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access."))); - }).pipe(Effect.provide(PrimaryEnvironmentHttpLayer.layer)); -} + yield* environmentClient.connect + .relayConfig({ + headers: {}, + payload: { + relayUrl: configuredRelayUrl, + relayIssuer: link.relayIssuer, + cloudUserId: link.cloudUserId, + environmentCredential: link.environmentCredential, + cloudMintPublicKey: link.cloudMintPublicKey, + endpointRuntime: link.endpointRuntime, + }, + }) + .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access."))); +}, Effect.provide(PrimaryEnvironmentHttpLayer.layer)); diff --git a/apps/web/src/cloud/managedAuth.test.ts b/apps/web/src/cloud/managedAuth.test.ts index 63001bdb184d..27f8a2187901 100644 --- a/apps/web/src/cloud/managedAuth.test.ts +++ b/apps/web/src/cloud/managedAuth.test.ts @@ -1,4 +1,4 @@ -import { managedRelaySessionAtom, setManagedRelaySession } from "@t3tools/client-runtime/relay"; +import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; import * as Effect from "effect/Effect"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; @@ -44,15 +44,4 @@ describe("managed relay authentication", () => { expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull(); await cleanup; }); - - it("replaces an existing account session atomically", () => { - setManagedRelaySession(appAtomRegistry, { - accountId: "account-1", - readClerkToken: async () => "account-1-token", - }); - - activateManagedRelayAuthentication("account-2", async () => "account-2-token"); - - expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-2"); - }); }); diff --git a/apps/web/src/cloud/publicConfig.test.ts b/apps/web/src/cloud/publicConfig.test.ts index d42aa34baa26..182367a410de 100644 --- a/apps/web/src/cloud/publicConfig.test.ts +++ b/apps/web/src/cloud/publicConfig.test.ts @@ -1,32 +1,13 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { - CloudPublicConfigMissingError, - hasCloudPublicConfig, - resolveRelayClerkTokenOptions, -} from "./publicConfig.ts"; +import { hasCloudPublicConfig } from "./publicConfig.ts"; afterEach(() => { vi.unstubAllEnvs(); + vi.unstubAllGlobals(); }); describe("hasCloudPublicConfig", () => { - it("requires both public cloud values", () => { - vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", ""); - vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", ""); - vi.stubEnv("VITE_T3CODE_RELAY_URL", ""); - expect(hasCloudPublicConfig()).toBe(false); - - vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", "pk_test_example"); - expect(hasCloudPublicConfig()).toBe(false); - - vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay"); - expect(hasCloudPublicConfig()).toBe(false); - - vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); - expect(hasCloudPublicConfig()).toBe(true); - }); - it("rejects an insecure relay URL", () => { vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", "pk_test_example"); vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay"); @@ -35,11 +16,63 @@ describe("hasCloudPublicConfig", () => { expect(hasCloudPublicConfig()).toBe(false); }); - it("reports the missing Clerk JWT template as structured configuration", () => { - vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", ""); + describe("browser origins", () => { + beforeEach(() => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", `pk_live_${btoa("clerk.example.com$")}`); + vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay"); + vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); + }); + + it.each(["https://example.com", "https://nightly.app.example.com:443"])( + "enables production auth on the configured root and subdomains: %s", + (origin) => { + vi.stubGlobal("window", { location: new URL(origin) }); + + expect(hasCloudPublicConfig()).toBe(true); + }, + ); + + it.each(["http://localhost:3773", "https://machine.example.ts.net"])( + "disables production auth on private browser origins: %s", + (origin) => { + vi.stubGlobal("window", { location: new URL(origin) }); + + expect(hasCloudPublicConfig()).toBe(false); + }, + ); + + it.each(["http://app.example.com:443", "https://app.example.com:3773"])( + "rejects unsupported schemes and ports even on the configured domain: %s", + (origin) => { + vi.stubGlobal("window", { location: new URL(origin) }); + + expect(hasCloudPublicConfig()).toBe(false); + }, + ); + + it.each(["https://notexample.com", "https://example.com.evil.test"])( + "rejects domain lookalikes: %s", + (origin) => { + vi.stubGlobal("window", { location: new URL(origin) }); - expect(() => resolveRelayClerkTokenOptions()).toThrowError( - new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }), + expect(hasCloudPublicConfig()).toBe(false); + }, ); + + it.each([ + ["https://tenant.clerk.accounts.dev", true], + ["https://other.clerk.accounts.dev", false], + ])("keeps a provider hostname scoped to its own instance: %s", (origin, expected) => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", `pk_live_${btoa("tenant.clerk.accounts.dev$")}`); + vi.stubGlobal("window", { location: new URL(origin) }); + + expect(hasCloudPublicConfig()).toBe(expected); + }); + + it("keeps production keys available in the Electron auth shell", () => { + vi.stubGlobal("window", { location: new URL("file:///app/index.html"), desktopBridge: {} }); + + expect(hasCloudPublicConfig()).toBe(true); + }); }); }); diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts index 8c3b9073e3b4..9f1238b858a5 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -1,4 +1,7 @@ -import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; +import { + clerkFrontendApiHostnameFromPublishableKey, + relayClerkTokenOptions, +} from "@t3tools/shared/relayAuth"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Schema from "effect/Schema"; @@ -59,6 +62,13 @@ export function resolveCloudPublicConfig(): CloudPublicConfig { } export function resolveRelayTracingConfig() { + // The relay exporter is created during module initialization, so this must be synchronous. + if ( + typeof window !== "undefined" && + window.desktopBridge?.getRelayTelemetryEnabled?.() === false + ) { + return null; + } const { relayTracing } = resolveCloudPublicConfig(); return relayTracing.tracesUrl && relayTracing.tracesDataset && relayTracing.tracesToken ? { @@ -71,7 +81,34 @@ export function resolveRelayTracingConfig() { export function hasCloudPublicConfig(): boolean { const config = resolveCloudPublicConfig(); - return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl); + if (!config.clerkPublishableKey || !config.clerkJwtTemplate || !config.relayUrl) { + return false; + } + + // Electron has its own auth runtime; development keys also support private browser origins. + if ( + typeof window === "undefined" || + window.desktopBridge !== undefined || + !config.clerkPublishableKey.startsWith("pk_live_") + ) { + return true; + } + + try { + // Custom Frontend API hosts use clerk.. + const domain = clerkFrontendApiHostnameFromPublishableKey(config.clerkPublishableKey).replace( + /^clerk\./u, + "", + ); + const { hostname, protocol, port } = window.location; + return ( + protocol === "https:" && + port === "" && + (hostname === domain || hostname.endsWith(`.${domain}`)) + ); + } catch { + return false; + } } export function resolveRelayClerkTokenOptions() { diff --git a/apps/web/src/cloud/relayClientInstallDialog.test.ts b/apps/web/src/cloud/relayClientInstallDialog.test.ts index 8ec1820d1115..2f505df24fb5 100644 --- a/apps/web/src/cloud/relayClientInstallDialog.test.ts +++ b/apps/web/src/cloud/relayClientInstallDialog.test.ts @@ -1,9 +1,6 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { - completeRelayClientInstallDialogClose, - finishRelayClientInstall, - readRelayClientInstallDialogState, RelayClientInstallConfirmationConflictError, reportRelayClientInstallProgress, requestRelayClientInstallConfirmation, @@ -16,59 +13,6 @@ describe("relay client install dialog coordinator", () => { resetRelayClientInstallDialogForTests(); }); - it("moves a confirmed installation through streamed progress stages", async () => { - const confirmation = requestRelayClientInstallConfirmation("2026.10.0"); - expect(readRelayClientInstallDialogState()).toEqual({ - status: "confirming", - version: "2026.10.0", - }); - - respondToRelayClientInstallConfirmation(true); - await expect(confirmation).resolves.toBe(true); - expect(readRelayClientInstallDialogState()).toEqual({ - status: "installing", - version: "2026.10.0", - stage: "checking", - }); - - reportRelayClientInstallProgress({ type: "progress", stage: "downloading" }); - expect(readRelayClientInstallDialogState()).toEqual({ - status: "installing", - version: "2026.10.0", - stage: "downloading", - }); - - finishRelayClientInstall(); - expect(readRelayClientInstallDialogState()).toEqual({ - status: "closing", - view: { - status: "installing", - version: "2026.10.0", - stage: "downloading", - }, - }); - - completeRelayClientInstallDialogClose(); - expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); - }); - - it("returns to idle when installation is declined", async () => { - const confirmation = requestRelayClientInstallConfirmation("2026.10.0"); - respondToRelayClientInstallConfirmation(false); - - await expect(confirmation).resolves.toBe(false); - expect(readRelayClientInstallDialogState()).toEqual({ - status: "closing", - view: { - status: "confirming", - version: "2026.10.0", - }, - }); - - completeRelayClientInstallDialogClose(); - expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); - }); - it("rejects concurrent confirmation with the active install state", async () => { const confirmation = requestRelayClientInstallConfirmation("2026.10.0"); respondToRelayClientInstallConfirmation(true); diff --git a/apps/web/src/cloud/useCloudLinkController.browser.test.tsx b/apps/web/src/cloud/useCloudLinkController.browser.test.tsx index 2eae7bb8430c..305094898514 100644 --- a/apps/web/src/cloud/useCloudLinkController.browser.test.tsx +++ b/apps/web/src/cloud/useCloudLinkController.browser.test.tsx @@ -6,9 +6,10 @@ import { } from "@t3tools/contracts"; import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay"; import { AsyncResult } from "effect/reactivity"; -import { act, useLayoutEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { useLayoutEffect } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; const testState = vi.hoisted(() => ({ scopes: new Set(), @@ -105,29 +106,38 @@ const linkedState: EnvironmentCloudLinkStateResult = { publishAgentActivity: false, }; -let root: Root | null = null; -let controller: ReturnType | null = null; +type Controller = ReturnType; -function ControllerProbe() { +function ControllerProbe({ onRender }: { onRender: (controller: Controller) => void }) { const value = useCloudLinkController(); useLayoutEffect(() => { - controller = value; + onRender(value); }); return null; } async function mountController() { - root = createRoot(document.createElement("div")); - await act(async () => root!.render()); -} - -async function reconcile(desired: CloudLinkDesiredState) { - let succeeded = false; - await act(async () => { - if (controller === null) throw new Error("Controller is not mounted."); - succeeded = await controller.reconcileCloudState(desired); + let controller: Controller | null = null; + const screen = await render( + { + controller = value; + }} + />, + ); + return Object.assign(screen, { + get controller() { + return controller; + }, + async reconcile(desired: CloudLinkDesiredState) { + let succeeded = false; + await act(async () => { + if (controller === null) throw new Error("Controller is not mounted."); + succeeded = await controller.reconcileCloudState(desired); + }); + return succeeded; + }, }); - return succeeded; } function expectNoMutations() { @@ -136,9 +146,7 @@ function expectNoMutations() { expect(testState.preferences).not.toHaveBeenCalled(); } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.spyOn(console, "error").mockImplementation(() => undefined); +function setup() { testState.scopes = new Set([AuthRelayReadScope, AuthRelayWriteScope]); testState.snapshot = linkedState; testState.renderedSnapshot = linkedState; @@ -154,34 +162,29 @@ beforeEach(() => { } testState.refreshLink.mockReset(); testState.toast.mockReset(); -}); - -afterEach(async () => { - await act(async () => root?.unmount()); - root = null; - controller = null; - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + return vi.spyOn(console, "error").mockImplementation(() => undefined); +} describe("useCloudLinkController", () => { it.each([AuthRelayReadScope, AuthRelayWriteScope])( "does not mutate without %s, even with a cached link snapshot", async (scope) => { - await mountController(); + using _setup = setup(); + await using screen = await mountController(); testState.scopes.delete(scope); - expect(await reconcile({ managedTunnel: true, publish: true })).toBe(false); + expect(await screen.reconcile({ managedTunnel: true, publish: true })).toBe(false); expect(testState.getToken).not.toHaveBeenCalled(); expectNoMutations(); }, ); it("does not interpret unavailable link state as an unlinked environment", async () => { + using _setup = setup(); testState.snapshot = null; - await mountController(); + await using screen = await mountController(); - expect(await reconcile({ managedTunnel: false, publish: true })).toBe(false); + expect(await screen.reconcile({ managedTunnel: false, publish: true })).toBe(false); expect(testState.getToken).not.toHaveBeenCalled(); expectNoMutations(); }); @@ -189,123 +192,38 @@ describe("useCloudLinkController", () => { it.each([AuthRelayReadScope, AuthRelayWriteScope])( "rechecks %s after requesting the cloud token", async (scope) => { - await mountController(); + using _setup = setup(); + await using screen = await mountController(); testState.getToken.mockImplementationOnce(async () => { testState.scopes.delete(scope); return "clerk-token"; }); - expect(await reconcile({ managedTunnel: false, publish: false })).toBe(false); + expect(await screen.reconcile({ managedTunnel: false, publish: false })).toBe(false); expectNoMutations(); }, ); it("stops if the link-state read fails while requesting the cloud token", async () => { - await mountController(); + using _setup = setup(); + await using screen = await mountController(); testState.getToken.mockImplementationOnce(async () => { testState.snapshot = null; return "clerk-token"; }); - expect(await reconcile({ managedTunnel: true, publish: true })).toBe(false); + expect(await screen.reconcile({ managedTunnel: true, publish: true })).toBe(false); expectNoMutations(); }); - it("uses the latest linked mode to change publishing without replacing the tunnel", async () => { - testState.snapshot = { ...linkedState, linked: false, managedTunnelActive: false }; - testState.renderedSnapshot = testState.snapshot; - await mountController(); - testState.getToken.mockImplementationOnce(async () => { - testState.snapshot = linkedState; - return "clerk-token"; - }); - - expect(await reconcile({ managedTunnel: true, publish: true })).toBe(true); - expect(testState.link).not.toHaveBeenCalled(); - expect(testState.unlink).not.toHaveBeenCalled(); - expect(testState.preferences).toHaveBeenCalledExactlyOnceWith({ - target, - publishAgentActivity: true, - }); - }); - - it("shows a link the relay holds as publish-only as off and relinks it as managed", async () => { - testState.relayProviderKind = "manual"; - await mountController(); - - expect(controller?.managedTunnelActive).toBe(false); - expect(controller?.managedTunnelOutOfSync).toBe(true); - expect(await reconcile({ managedTunnel: true, publish: false })).toBe(true); - expect(testState.link).toHaveBeenCalledExactlyOnceWith({ - target, - clerkToken: "clerk-token", - mode: "managed", - }); - }); - - it("shows a revoked link as off and links it again when turned on", async () => { - testState.snapshot = { ...linkedState, linkRevoked: true }; - testState.renderedSnapshot = testState.snapshot; - await mountController(); - expect(controller).toMatchObject({ - linked: false, - linkRevoked: true, - managedTunnelActive: false, - managedTunnelOutOfSync: false, - }); - - expect(await reconcile({ managedTunnel: true, publish: false })).toBe(true); - expect(testState.link).toHaveBeenCalledExactlyOnceWith({ - target, - clerkToken: "clerk-token", - mode: "managed", - }); - }); - - it("keeps a managed link that the relay also holds as managed", async () => { - testState.relayProviderKind = "cloudflare_tunnel"; - await mountController(); - - expect(controller?.managedTunnelActive).toBe(true); - expect(controller?.managedTunnelOutOfSync).toBe(false); - expect(await reconcile({ managedTunnel: true, publish: false })).toBe(true); - expect(testState.link).not.toHaveBeenCalled(); - }); - - it("stops the preference write if management permission is lost after linking", async () => { - await mountController(); - testState.link.mockImplementationOnce(async () => { - testState.scopes.delete(AuthRelayWriteScope); - return AsyncResult.success({}); - }); - - expect(await reconcile({ managedTunnel: false, publish: true })).toBe(false); - expect(testState.link).toHaveBeenCalledOnce(); - expect(testState.preferences).not.toHaveBeenCalled(); - expect(testState.refreshLink).toHaveBeenCalledOnce(); - }); - it("still lets an authorized user unlink when the cloud token is unavailable", async () => { - await mountController(); + using _setup = setup(); + await using screen = await mountController(); testState.getToken.mockRejectedValueOnce(new Error("Cloud sign-in unavailable")); - expect(await reconcile({ managedTunnel: false, publish: false })).toBe(true); - expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); - expect(testState.link).not.toHaveBeenCalled(); - expect(testState.preferences).not.toHaveBeenCalled(); - }); - - it("unlinks if the link-state read fails while requesting the cloud token", async () => { - await mountController(); - testState.getToken.mockImplementationOnce(async () => { - testState.snapshot = null; - throw new Error("Cloud sign-in unavailable"); - }); - - expect(await reconcile({ managedTunnel: false, publish: false })).toBe(true); + expect(await screen.reconcile({ managedTunnel: false, publish: false })).toBe(true); expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); expect(testState.link).not.toHaveBeenCalled(); expect(testState.preferences).not.toHaveBeenCalled(); - expect(testState.refreshLink).toHaveBeenCalledOnce(); }); }); diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index e8e8cc25fe61..fe30e76a94c2 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -7,19 +7,8 @@ export interface CommandPaletteLinkedThreads { const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; -/** - * Starts adding a project on one machine, cloning `remoteUrl` when it is set. - * A clone takes `title`, so a new checkout keeps a renamed project's name. - */ -export interface CommandPaletteAddProjectTarget { - readonly environmentId: EnvironmentId; - readonly remoteUrl: string | null; - readonly title?: string; -} - export interface CommandPaletteOpenDetail { readonly open?: "add-project" | "new-thread-in"; - readonly addProjectTarget?: CommandPaletteAddProjectTarget; readonly query?: string; readonly linkedThreads?: CommandPaletteLinkedThreads; } diff --git a/apps/web/src/components/AddProjectToMachineDialog.tsx b/apps/web/src/components/AddProjectToMachineDialog.tsx new file mode 100644 index 000000000000..7ac7bec95b59 --- /dev/null +++ b/apps/web/src/components/AddProjectToMachineDialog.tsx @@ -0,0 +1,434 @@ +import { useParams } from "@tanstack/react-router"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { + getAddProjectInitialQuery, + getCloneDestinationPath, + getCloneDirectoryName, +} from "@t3tools/client-runtime/operations/projects"; +import { + type EnvironmentId, + type ProjectIgnoredFile, + type ScopedProjectRef, + resolveEnvironmentMachineKind, +} from "@t3tools/contracts"; +import { + type CloneProtocol, + cloneUrlForProtocol, + cloneUrlProtocol, +} from "@t3tools/shared/cloneUrl"; +import { withoutRemoteCredentials } from "@t3tools/shared/git"; +import { ArrowRightIcon } from "lucide-react"; +import { useEffect, useId, useState } from "react"; +import { create } from "zustand"; + +import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; +import { useAddProjectTargets } from "../hooks/useAddProjectTargets"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments"; +import { + COPYABLE_FILE_MAX_BYTES, + addProjectToMachine, + isDefaultCopiedFile, +} from "../lib/addProjectToMachine"; +import { formatBytes } from "../lib/formatBytes"; +import { newProjectId } from "../lib/utils"; +import { useProject } from "../state/entities"; +import { useEnvironments } from "../state/environments"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentQuery } from "../state/query"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { Button } from "./ui/button"; +import { Checkbox } from "./ui/checkbox"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { MiddleTruncate } from "./ui/middle-truncate"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; +import { Toggle, ToggleGroup } from "./ui/toggle-group"; + +interface AddProjectToMachineRequest { + readonly projectRef: ScopedProjectRef; + readonly targetEnvironmentId: EnvironmentId | null; +} + +const useRequest = create<{ request: AddProjectToMachineRequest | null }>(() => ({ + request: null, +})); + +/** Opens the dialog that clones a project onto another connected machine. */ +export function openAddProjectToMachine(request: AddProjectToMachineRequest) { + useRequest.setState({ request }); +} + +const close = () => useRequest.setState({ request: null }); + +export function AddProjectToMachineDialogHost() { + const request = useRequest((state) => state.request); + return request ? : null; +} + +function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRequest }) { + const id = useId(); + const project = useProject(request.projectRef); + const handleNewThread = useNewThreadHandler(); + const { draftId: routeDraftId } = useParams({ strict: false }); + const { environments } = useEnvironments(); + const targets = useAddProjectTargets(project, useLogicalProjectEnvironments(project, false)); + const [targetId, setTargetId] = useState(request.targetEnvironmentId); + const target = targets.find((t) => t.environmentId === targetId) ?? targets[0] ?? null; + const source = environments.find((env) => env.environmentId === request.projectRef.environmentId); + // The checkout's raw remote can carry its token; the other machine brings its own credentials. + const sourceUrl = withoutRemoteCredentials( + project?.repositoryIdentity?.origin?.remoteUrl ?? + project?.repositoryIdentity?.locator.remoteUrl ?? + "", + ); + const [protocol, setProtocol] = useState(() => cloneUrlProtocol(sourceUrl)); + const sshUrl = cloneUrlForProtocol(sourceUrl, "ssh"); + const httpsUrl = cloneUrlForProtocol(sourceUrl, "https"); + const remoteUrl = + (protocol === "ssh" ? sshUrl : protocol === "https" ? httpsUrl : null) ?? sourceUrl; + + const targetEnvironment = environments.find((env) => env.environmentId === target?.environmentId); + const defaultPath = getCloneDestinationPath( + getAddProjectInitialQuery(targetEnvironment?.serverConfig?.settings.addProjectBaseDirectory), + getCloneDirectoryName(sourceUrl), + ); + const [pathOverride, setPathOverride] = useState(null); + const destinationPath = pathOverride ?? defaultPath; + + const canListFiles = source?.serverConfig?.environment.capabilities.ignoredFileListing === true; + const ignoredFiles = useEnvironmentQuery( + project && canListFiles + ? projectEnvironment.listIgnoredFiles({ + environmentId: project.environmentId, + input: { cwd: project.workspaceRoot }, + }) + : null, + ); + const files = ignoredFiles.data?.files ?? []; + const [selected, setSelected] = useState | null>(null); + const selectedFiles = selected ?? defaultSelection(files); + const copyable = files.filter((file) => file.byteLength <= COPYABLE_FILE_MAX_BYTES); + const selectedBytes = files + .filter((file) => selectedFiles.has(file.path)) + .reduce((total, file) => total + file.byteLength, 0); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // The project or every target can vanish while the dialog is open. Submitting + // before the file list loads would silently skip the default .env copies. + const unavailable = + project === null || + target === null || + (canListFiles && ignoredFiles.data === null && ignoredFiles.error === null); + useEffect(() => { + if (project === null) close(); + }, [project]); + + const toggleFile = (path: string, checked: boolean) => { + const next = new Set(selectedFiles); + if (checked) next.add(path); + else next.delete(path); + setSelected(next); + }; + + // Lands the user on the new machine right away; its composer shows the clone + // and holds sends until it finishes. A draft open on the source project moves + // over with whatever was typed in it. + const openClonedProject = (targetRef: ScopedProjectRef) => { + const drafts = useComposerDraftStore.getState(); + const draftId = routeDraftId as DraftId | undefined; + const draft = draftId ? drafts.getDraftSession(draftId) : null; + if ( + draftId && + draft && + !draft.promotedTo && + draft.environmentId === request.projectRef.environmentId && + draft.projectId === request.projectRef.projectId + ) { + drafts.setDraftThreadContext(draftId, { + projectRef: targetRef, + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + }); + return; + } + void handleNewThread(targetRef); + }; + + const submit = async () => { + if (!project || !target || submitting) return; + if (destinationPath.trim().length === 0) { + setError("Choose a folder on the other machine."); + return; + } + setSubmitting(true); + setError(null); + const projectId = newProjectId(); + try { + await addProjectToMachine({ + source: { environmentId: project.environmentId, cwd: project.workspaceRoot }, + subdirectory: projectSubdirectory( + project.repositoryIdentity?.rootPath, + project.workspaceRoot, + ), + target: { environmentId: target.environmentId, label: target.label }, + projectId, + title: project.title, + remoteUrl, + destinationPath: destinationPath.trim(), + files: files.filter((file) => selectedFiles.has(file.path)).map((file) => file.path), + }); + close(); + openClonedProject(scopeProjectRef(target.environmentId, projectId)); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not start the clone."); + setSubmitting(false); + } + }; + + return ( + { + if (!open) close(); + }} + > + +
{ + event.preventDefault(); + void submit(); + }} + > + + Add to another machine + + Clone {project?.repositoryIdentity?.displayName ?? project?.title} and bring the local + files git does not track. + + + +
+
+ + + {source?.label ?? "This machine"} + + + + +
+
+ git clone + + + + + + {selectedFiles.size === 0 + ? "no files" + : `+ ${selectedFiles.size} ${selectedFiles.size === 1 ? "file" : "files"}`} + +
+
+ {targets.length > 1 ? ( + + ) : ( + + {target ? ( + + ) : null} + {target?.label ?? "No machine available"} + + )} + + + +
+
+ +
+ + {sshUrl && httpsUrl ? ( + { + const value = next[0]; + if (value === "ssh" || value === "https") setProtocol(value); + }} + > + SSH + HTTPS + + ) : null} + {remoteUrl} +
+ +
+ + setPathOverride(event.target.value)} + spellCheck={false} + autoComplete="off" + /> +
+ + {canListFiles ? ( +
+
+ + {copyable.length > 0 ? ( + <> + + {selectedFiles.size} of {files.length} + {selectedBytes > 0 ? ` · ${formatBytes(selectedBytes)}` : ""} + + + + ) : null} +
+ {ignoredFiles.error ? ( +

{ignoredFiles.error}

+ ) : files.length === 0 ? ( +

+ {ignoredFiles.isSuccess + ? "No ignored files to copy." + : "Finding ignored files…"} +

+ ) : ( +
+ {files.map((file) => ( + toggleFile(file.path, checked)} + /> + ))} +
+ )} + {ignoredFiles.data?.truncated ? ( +

+ Showing the first {files.length} files. +

+ ) : null} +

+ Files are copied after the clone finishes. Folders git ignores whole, like + node_modules, are left out. +

+
+ ) : null} + + {error ?

{error}

: null} +
+ + + + +
+
+
+ ); +} + +/** + * A project can be rooted below its repository's top folder. The clone lands + * at the top, so copied files keep the same path below it. + */ +function projectSubdirectory(repositoryRoot: string | undefined, workspaceRoot: string) { + if (!repositoryRoot) return ""; + const prefix = repositoryRoot.replace(/[\\/]+$/, ""); + const rest = workspaceRoot.slice(prefix.length); + return workspaceRoot.startsWith(prefix) && /^[\\/]/.test(rest) + ? rest.slice(1).replaceAll("\\", "/") + : ""; +} + +function defaultSelection(files: readonly ProjectIgnoredFile[]): ReadonlySet { + return new Set( + files + .filter( + (file) => file.byteLength <= COPYABLE_FILE_MAX_BYTES && isDefaultCopiedFile(file.path), + ) + .map((file) => file.path), + ); +} + +function IgnoredFileRow({ + file, + checked, + onCheckedChange, +}: { + file: ProjectIgnoredFile; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + const tooLarge = file.byteLength > COPYABLE_FILE_MAX_BYTES; + return ( + + ); +} diff --git a/apps/web/src/components/AnimatedHeight.tsx b/apps/web/src/components/AnimatedHeight.tsx index dabb2abaeab4..2c4432583205 100644 --- a/apps/web/src/components/AnimatedHeight.tsx +++ b/apps/web/src/components/AnimatedHeight.tsx @@ -1,5 +1,3 @@ -"use client"; - import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; import { observeResize } from "../lib/observeResize"; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 89d52c87b282..526ac86a6c9a 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { useEffect, + useRef, useState, useSyncExternalStore, type CSSProperties, @@ -9,7 +10,13 @@ import { } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; +import { isCommandPaletteOpen } from "../commandPaletteBus"; import { isElectron } from "../env"; +import { + releaseThreadUndoShortcut, + threadUndoClaimsShortcut, + undoLatestThreadAction, +} from "../hooks/showThreadUndoNotice"; import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; import { isRichTextBoldShortcut, @@ -199,7 +206,21 @@ function SidebarControl() { ); } -function NavigationHistoryShortcuts() { +function routePanelsOpen(routeThreadRef: ReturnType) { + if (!routeThreadRef) return { terminalOpen: false, previewOpen: false }; + return { + terminalOpen: selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + routeThreadRef, + ).terminalOpen, + previewOpen: + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === + "preview", + }; +} + +export function NavigationHistoryShortcuts() { + const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const routeThreadRef = useThreadRouteRef(); @@ -215,19 +236,11 @@ function NavigationHistoryShortcuts() { const command = resolveShortcutCommand(event, keybindings, { context: { terminalFocus: isTerminalFocused(), - terminalOpen: routeThreadRef - ? selectThreadTerminalUiState( - useTerminalUiStateStore.getState().terminalUiStateByThreadKey, - routeThreadRef, - ).terminalOpen - : false, previewFocus: isPreviewFocused(), - previewOpen: routeThreadRef - ? selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === - "preview" - : false, + ...routePanelsOpen(routeThreadRef), editableFocus: isEditableFocused(event.target), modelPickerOpen: isModelPickerOpen(), + usagePageOpen, }, }); if (command !== "navigation.back" && command !== "navigation.forward") return; @@ -240,7 +253,68 @@ function NavigationHistoryShortcuts() { window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, routeThreadRef]); + }, [keybindings, routeThreadRef, usagePageOpen]); + + return null; +} + +const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]); + +// Undoes the thread actions in the sidebar's notice. Mounted wherever the +// thread sidebar can show; Settings replaces it. Listens in the capture phase +// so mod+z reaches it before the composer's own undo handler consumes the key. +function ThreadUndoShortcut() { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const routeThreadRef = useThreadRouteRef(); + // A collapsed sidebar hides the notice, so text fields keep their own undo. + const noticeVisible = useSidebarVisibility(); + // Outlives the effect: undoing an archive navigates, which re-runs it mid-hold. + const heldUndoKey = useRef(null); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + // A held key undoes once. Cancel its repeats so they cannot fall through + // to the editor's own undo, even after the last notice is gone. + if (event.repeat) { + if (event.code === heldUndoKey.current) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + // A modifier tapped mid-hold leaves the held key repeating. + if (!MODIFIER_KEYS.has(event.key)) heldUndoKey.current = null; + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + previewFocus: isPreviewFocused(), + ...routePanelsOpen(routeThreadRef), + editableFocus: + isEditableFocused(event.target) && !(noticeVisible && threadUndoClaimsShortcut()), + modelPickerOpen: isModelPickerOpen(), + }, + }); + if (command !== "thread.undo") { + if (!MODIFIER_KEYS.has(event.key)) releaseThreadUndoShortcut(); + return; + } + if (isCommandPaletteOpen() || isModelPickerOpen()) return; + if (undoLatestThreadAction()) { + heldUndoKey.current = event.code; + event.preventDefault(); + event.stopPropagation(); + } + }; + // Pastes from a menu, dictation and drops edit text without a keypress. + const onInput = () => releaseThreadUndoShortcut(); + + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("input", onInput, true); + return () => { + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("input", onInput, true); + }; + }, [keybindings, noticeVisible, routeThreadRef]); return null; } @@ -357,6 +431,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {children} + {isOnSettings ? null : } diff --git a/apps/web/src/components/BranchPicker.tsx b/apps/web/src/components/BranchPicker.tsx index 1f9d54b8aabf..5a8a8483441d 100644 --- a/apps/web/src/components/BranchPicker.tsx +++ b/apps/web/src/components/BranchPicker.tsx @@ -1,4 +1,4 @@ -import type { VcsRef, WorktreeStartRemote } from "@t3tools/contracts"; +import type { VcsRef } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { GitBranchIcon } from "lucide-react"; import { @@ -16,7 +16,6 @@ import { cn } from "../lib/utils"; import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches"; import { RefreshIcon } from "./ui/refresh-icon"; import { Switch } from "./ui/switch"; -import { Toggle, ToggleGroup } from "./ui/toggle-group"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; import { Combobox, @@ -44,7 +43,7 @@ export function BranchPicker({ onLoadNext, statusText, createBranchControl, - remoteControl, + originControl, popupProps, renderItem, getItemType, @@ -71,24 +70,14 @@ export function BranchPicker({ onCheckedChange: (checked: boolean) => void; } | undefined; - /** - * Where a new worktree starts. Off, origin, and upstream when `upstreamAvailable`, otherwise - * an origin switch. A saved upstream choice stays visible, disabled, until the user changes it. - */ - remoteControl?: - | { - value: WorktreeStartRemote; - upstreamAvailable: boolean; - onChange: (value: WorktreeStartRemote) => void; - } - | undefined; + originControl?: { checked: boolean; onCheckedChange: (checked: boolean) => void } | undefined; popupProps: Omit, "children">; renderItem: (value: string, index: number) => ReactNode; getItemType?: ((value: string) => string) | undefined; children: ReactNode; }) { const highlightedValueRef = useRef(null); - const startFromRemoteControlId = useId(); + const startFromOriginSwitchId = useId(); const createBranchSwitchId = useId(); const branchListScrollElementRef = useRef(null); const previousBranchListScrollTopRef = useRef(null); @@ -271,65 +260,32 @@ export function BranchPicker({ ) : null} - {remoteControl && - (remoteControl.upstreamAvailable || remoteControl.value === "upstream") ? ( -
- - - { - const remote = values[0]; - if (remote === "off" || remote === "origin" || remote === "upstream") { - remoteControl.onChange(remote === "off" ? null : remote); - } - }} - > - Off - origin - - upstream - - - - {remoteControl.upstreamAvailable - ? "Start from the latest matching remote branch, or turn off to use the selected ref." - : "The upstream choice requires remotes named origin and upstream. Choose another source to continue."} - -
- ) : remoteControl ? ( + {originControl ? ( - remoteControl.onChange(checked ? "origin" : null) - } + aria-label="Start worktree from remote" + onCheckedChange={(checked) => originControl.onCheckedChange(Boolean(checked))} /> } /> - Creates the worktree from the latest matching branch on origin instead of your local - branch. + Uses the latest commit of the base branch’s tracked remote branch, even if its name + differs. Otherwise uses the same-named branch on origin. Uses your local branch if + that remote branch is missing. ) : null} @@ -371,11 +327,11 @@ export function BranchPickerRefItem({ : null; return ( diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index fe035fc8f0ff..3ab0278c9c2b 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -1,117 +1,21 @@ -import { EnvironmentId, type VcsRef } from "@t3tools/contracts"; +import { type VcsRef } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, - resolveEnvironmentOptionLabel, resolveBranchSelectionTarget, - resolveCurrentWorkspaceLabel, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, - resolveEnvModeLabel, - resolveBranchTriggerLabel, resolveBranchToolbarPrBranch, resolveBranchToolbarValue, + resolveBranchTriggerLabel, resolveSelectedBranchRef, - resolveLockedWorkspaceLabel, resolveWorktreeBaseBranchCandidate, resolveLocalCheckoutBranchMismatch, - resolvePreviousWorktreeLabel, - resolvePreviousWorktreeSeed, sanitizeNewRefName, shouldIncludeBranchPickerItem, - shouldShowComposerContextStrip, - shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; -const localEnvironmentId = EnvironmentId.make("environment-local"); -const remoteEnvironmentId = EnvironmentId.make("environment-remote"); - -describe("resolvePreviousWorktreeSeed", () => { - it("picks the most recently updated worktree thread", () => { - expect( - resolvePreviousWorktreeSeed({ - threads: [ - { - branch: "t3/older", - worktreePath: "/repo/.t3/worktrees/older", - updatedAt: "2026-07-20T00:00:00.000Z", - }, - { - branch: "t3/newer", - worktreePath: "/repo/.t3/worktrees/newer", - updatedAt: "2026-07-22T00:00:00.000Z", - }, - { branch: "main", worktreePath: null, updatedAt: "2026-07-23T00:00:00.000Z" }, - ], - currentWorktreePath: null, - }), - ).toEqual({ branch: "t3/newer", worktreePath: "/repo/.t3/worktrees/newer" }); - }); - - it("skips the worktree the composer already points at", () => { - expect( - resolvePreviousWorktreeSeed({ - threads: [ - { - branch: "t3/current", - worktreePath: "/repo/.t3/worktrees/current", - updatedAt: "2026-07-22T00:00:00.000Z", - }, - ], - currentWorktreePath: "/repo/.t3/worktrees/current", - }), - ).toBeNull(); - }); - - it("returns null when no thread has a worktree", () => { - expect( - resolvePreviousWorktreeSeed({ - threads: [{ branch: "main", worktreePath: null, updatedAt: "2026-07-22T00:00:00.000Z" }], - currentWorktreePath: null, - }), - ).toBeNull(); - }); - - it("ignores archived threads and threads with unparseable timestamps", () => { - expect( - resolvePreviousWorktreeSeed({ - threads: [ - { - branch: "t3/archived", - worktreePath: "/repo/.t3/worktrees/archived", - updatedAt: "2026-07-23T00:00:00.000Z", - archivedAt: "2026-07-23T01:00:00.000Z", - }, - { - branch: "t3/garbage-timestamp", - worktreePath: "/repo/.t3/worktrees/garbage", - updatedAt: "not-a-date", - }, - { - branch: "t3/live", - worktreePath: "/repo/.t3/worktrees/live", - updatedAt: "2026-07-21T00:00:00.000Z", - archivedAt: null, - }, - ], - currentWorktreePath: null, - }), - ).toEqual({ branch: "t3/live", worktreePath: "/repo/.t3/worktrees/live" }); - }); -}); - -describe("resolvePreviousWorktreeLabel", () => { - it("includes the branch when known", () => { - expect(resolvePreviousWorktreeLabel({ branch: "t3/fix-thing", worktreePath: "/wt" })).toBe( - "Previous worktree (t3/fix-thing)", - ); - expect(resolvePreviousWorktreeLabel({ branch: null, worktreePath: "/wt" })).toBe( - "Previous worktree", - ); - }); -}); - describe("resolveDraftEnvModeAfterBranchChange", () => { it("switches to local mode when returning from an existing worktree to the main worktree", () => { expect( @@ -123,16 +27,6 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { ).toBe("local"); }); - it("keeps new-worktree mode when selecting a base ref before worktree creation", () => { - expect( - resolveDraftEnvModeAfterBranchChange({ - nextWorktreePath: null, - currentWorktreePath: null, - effectiveEnvMode: "worktree", - }), - ).toBe("worktree"); - }); - it("uses worktree mode when selecting a ref already attached to a worktree", () => { expect( resolveDraftEnvModeAfterBranchChange({ @@ -168,39 +62,6 @@ describe("resolveBranchToolbarValue", () => { }), ).toBe("feature/existing"); }); - - it("defaults new-worktree mode to current git ref when no explicit base ref is set", () => { - expect( - resolveBranchToolbarValue({ - envMode: "worktree", - activeWorktreePath: null, - activeThreadBranch: null, - currentGitBranch: "main", - }), - ).toBe("main"); - }); - - it("keeps an explicitly selected worktree base ref", () => { - expect( - resolveBranchToolbarValue({ - envMode: "worktree", - activeWorktreePath: null, - activeThreadBranch: "feature/base", - currentGitBranch: "main", - }), - ).toBe("feature/base"); - }); - - it("shows the actual checked-out ref when not selecting a new worktree base", () => { - expect( - resolveBranchToolbarValue({ - envMode: "local", - activeWorktreePath: null, - activeThreadBranch: "feature/base", - currentGitBranch: "main", - }), - ).toBe("main"); - }); }); describe("resolveWorktreeBaseBranchCandidate", () => { @@ -295,10 +156,9 @@ describe("resolveSelectedBranchRef", () => { effectiveEnvMode: "worktree", resolvedActiveBranch: selected?.name ?? null, resolvedActiveBranchIsRemote: selected?.isRemote === true, - resolvedActiveBranchRemoteName: selected?.remoteName ?? null, - startFromRemote: "upstream", + startFromOrigin: true, }), - ).toBe("From upstream/origin/topic"); + ).toBe("From origin/topic (remote)"); }); it("uses the remote ref when no local branch has the name", () => { @@ -314,183 +174,28 @@ describe("resolveSelectedBranchRef", () => { effectiveEnvMode: "worktree", resolvedActiveBranch: selected?.name ?? null, resolvedActiveBranchIsRemote: selected?.isRemote === true, - resolvedActiveBranchRemoteName: selected?.remoteName ?? null, - startFromRemote: "upstream", + startFromOrigin: true, }), - ).toBe("From upstream/topic"); + ).toBe("From origin/topic"); }); }); describe("resolveBranchTriggerLabel", () => { - it.each(["main", "feature/demo"])("shows the upstream source for local branch %s", (branch) => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: branch, - resolvedActiveBranchIsRemote: false, - startFromRemote: "upstream", - }), - ).toBe(`From upstream/${branch}`); - }); - - it.each(["origin/feature/demo", "upstream/feature/demo"])( - "shows upstream when the selected ref is %s", - (branch) => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: branch, - resolvedActiveBranchIsRemote: true, - startFromRemote: "upstream", - }), - ).toBe("From upstream/feature/demo"); - }, - ); - - it("keeps the selected ref when no remote is chosen", () => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: "origin/feature/demo", - resolvedActiveBranchIsRemote: true, - startFromRemote: null, - }), - ).toBe("From origin/feature/demo"); - }); - - it("uses remote metadata when the remote name contains slashes", () => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: "my-org/fork/feature/demo", - resolvedActiveBranchIsRemote: true, - resolvedActiveBranchRemoteName: "my-org/fork", - startFromRemote: "upstream", - }), - ).toBe("From upstream/feature/demo"); - }); - - it("labels an existing branch without a base or origin prefix", () => { + it("labels an existing branch without a base or remote marker", () => { expect( resolveBranchTriggerLabel({ activeWorktreePath: null, effectiveEnvMode: "worktree", resolvedActiveBranch: "feature/existing", resolvedActiveBranchIsRemote: false, - startFromRemote: "origin", + startFromOrigin: true, createNewBranch: false, }), ).toBe("feature/existing"); }); - - it("shows the origin ref when a new worktree will start from origin", () => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: "main", - resolvedActiveBranchIsRemote: false, - startFromRemote: "origin", - }), - ).toBe("From origin/main"); - }); - - it("shows the origin ref for local branch names that contain slashes", () => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: "feature/demo", - resolvedActiveBranchIsRemote: false, - startFromRemote: "origin", - }), - ).toBe("From origin/feature/demo"); - }); - - it("shows the local ref when start from origin is disabled", () => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: "main", - resolvedActiveBranchIsRemote: false, - startFromRemote: null, - }), - ).toBe("From main"); - }); - - it("does not duplicate the origin prefix for an explicit remote ref", () => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: "origin/feature/demo", - resolvedActiveBranchIsRemote: true, - startFromRemote: "origin", - }), - ).toBe("From origin/feature/demo"); - }); - - it("uses the chosen remote for an explicit ref from another remote", () => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: "upstream/feature/demo", - resolvedActiveBranchIsRemote: true, - startFromRemote: "origin", - }), - ).toBe("From origin/feature/demo"); - }); - - it("keeps current-checkout labels and empty state unchanged", () => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "local", - resolvedActiveBranch: "main", - resolvedActiveBranchIsRemote: false, - startFromRemote: "origin", - }), - ).toBe("main"); - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: null, - resolvedActiveBranchIsRemote: null, - startFromRemote: "origin", - }), - ).toBe("Select ref"); - }); - - it("does not fabricate an origin ref while branch metadata is loading", () => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: "upstream/feature/demo", - resolvedActiveBranchIsRemote: null, - startFromRemote: "origin", - }), - ).toBe("From upstream/feature/demo"); - }); }); describe("resolveBranchToolbarPrBranch", () => { - it("uses the explicit thread branch when it matches the displayed branch", () => { - expect( - resolveBranchToolbarPrBranch({ - activeThreadBranch: "feature/current", - resolvedActiveBranch: "feature/current", - }), - ).toBe("feature/current"); - }); - it("hides PR state while an optimistic branch switch is in flight", () => { expect( resolveBranchToolbarPrBranch({ @@ -499,15 +204,6 @@ describe("resolveBranchToolbarPrBranch", () => { }), ).toBeNull(); }); - - it("does not infer PR state without an explicit thread branch", () => { - expect( - resolveBranchToolbarPrBranch({ - activeThreadBranch: null, - resolvedActiveBranch: "feature/current", - }), - ).toBeNull(); - }); }); describe("resolveLocalCheckoutBranchMismatch", () => { @@ -525,17 +221,6 @@ describe("resolveLocalCheckoutBranchMismatch", () => { }); }); - it("ignores matching local checkout state", () => { - expect( - resolveLocalCheckoutBranchMismatch({ - effectiveEnvMode: "local", - activeWorktreePath: null, - activeThreadBranch: "feature/thread", - currentGitBranch: "feature/thread", - }), - ).toBeNull(); - }); - it("ignores dedicated worktrees because their checkout is already thread-scoped", () => { expect( resolveLocalCheckoutBranchMismatch({ @@ -546,184 +231,9 @@ describe("resolveLocalCheckoutBranchMismatch", () => { }), ).toBeNull(); }); - - it("ignores new-worktree base selection before a worktree exists", () => { - expect( - resolveLocalCheckoutBranchMismatch({ - effectiveEnvMode: "worktree", - activeWorktreePath: null, - activeThreadBranch: "feature/base", - currentGitBranch: "main", - }), - ).toBeNull(); - }); -}); - -describe("resolveEnvironmentOptionLabel", () => { - it("prefers the primary environment's machine label", () => { - expect( - resolveEnvironmentOptionLabel({ - isPrimary: true, - environmentId: localEnvironmentId, - runtimeLabel: "Julius's Mac mini", - savedLabel: "Local environment", - }), - ).toBe("Julius's Mac mini"); - }); - - it("falls back to 'This device' for generic primary labels", () => { - expect( - resolveEnvironmentOptionLabel({ - isPrimary: true, - environmentId: localEnvironmentId, - runtimeLabel: "Local environment", - savedLabel: "Local", - }), - ).toBe("This device"); - }); - - it("keeps configured labels for non-primary environments", () => { - expect( - resolveEnvironmentOptionLabel({ - isPrimary: false, - environmentId: remoteEnvironmentId, - runtimeLabel: null, - savedLabel: "Build box", - }), - ).toBe("Build box"); - }); -}); - -describe("shouldShowEnvironmentIndicator", () => { - it("shows the indicator whenever multiple environments are pickable", () => { - expect( - shouldShowEnvironmentIndicator({ - activeEnvironment: { isPrimary: true }, - canPickEnvironment: true, - }), - ).toBe(true); - }); - - it("shows a sole remote environment so the user knows where the project runs", () => { - expect( - shouldShowEnvironmentIndicator({ - activeEnvironment: { isPrimary: false }, - canPickEnvironment: false, - }), - ).toBe(true); - }); - - it("hides a sole primary (this-device) environment", () => { - expect( - shouldShowEnvironmentIndicator({ - activeEnvironment: { isPrimary: true }, - canPickEnvironment: false, - }), - ).toBe(false); - }); - - it("hides the indicator when the active environment is unknown", () => { - expect( - shouldShowEnvironmentIndicator({ - activeEnvironment: null, - canPickEnvironment: false, - }), - ).toBe(false); - }); -}); - -describe("shouldShowComposerContextStrip", () => { - it.each([false, true])( - "honors the active-thread preference with resting controls %s", - (hostsRestingComposerControls) => { - const input = { - isDraftHeroState: false, - hasActiveProject: true, - isGitRepo: true, - showEnvironmentIndicator: true, - hostsRestingComposerControls, - }; - expect(shouldShowComposerContextStrip({ ...input, persistInActiveThreads: false })).toBe( - false, - ); - expect(shouldShowComposerContextStrip({ ...input, persistInActiveThreads: true })).toBe(true); - }, - ); - - it("keeps the environment indicator visible for a non-Git project", () => { - expect( - shouldShowComposerContextStrip({ - isDraftHeroState: true, - persistInActiveThreads: false, - hasActiveProject: true, - isGitRepo: false, - showEnvironmentIndicator: true, - hostsRestingComposerControls: false, - }), - ).toBe(true); - }); - - it("hides the strip when a non-Git project has nothing to show", () => { - expect( - shouldShowComposerContextStrip({ - isDraftHeroState: true, - persistInActiveThreads: false, - hasActiveProject: true, - isGitRepo: false, - showEnvironmentIndicator: false, - hostsRestingComposerControls: false, - }), - ).toBe(false); - }); - - it("keeps the strip for visible resting composer controls in a non-Git thread", () => { - expect( - shouldShowComposerContextStrip({ - isDraftHeroState: true, - persistInActiveThreads: false, - hasActiveProject: true, - isGitRepo: false, - showEnvironmentIndicator: false, - hostsRestingComposerControls: true, - }), - ).toBe(true); - }); - - it("shows Git controls without requiring an environment indicator", () => { - expect( - shouldShowComposerContextStrip({ - isDraftHeroState: true, - persistInActiveThreads: false, - hasActiveProject: true, - isGitRepo: true, - showEnvironmentIndicator: false, - hostsRestingComposerControls: false, - }), - ).toBe(true); - }); }); describe("resolveEffectiveEnvMode", () => { - it("treats draft threads already attached to a worktree as current-checkout mode", () => { - expect( - resolveEffectiveEnvMode({ - activeWorktreePath: "/repo/.t3/worktrees/feature-a", - hasServerThread: false, - draftThreadEnvMode: "worktree", - }), - ).toBe("local"); - }); - - it("keeps explicit new-worktree mode for draft threads without a worktree path", () => { - expect( - resolveEffectiveEnvMode({ - activeWorktreePath: null, - hasServerThread: false, - draftThreadEnvMode: "worktree", - }), - ).toBe("worktree"); - }); - it("keeps a server thread in worktree mode while its worktree is still being created", () => { expect( resolveEffectiveEnvMode({ @@ -743,54 +253,12 @@ describe("resolveEffectiveEnvMode", () => { }); }); -describe("resolveEnvModeLabel", () => { - it("uses explicit workspace labels", () => { - expect(resolveEnvModeLabel("local")).toBe("Current checkout"); - expect(resolveEnvModeLabel("worktree")).toBe("New worktree"); - }); -}); - -describe("resolveCurrentWorkspaceLabel", () => { - it("describes the main repo checkout when no worktree path is active", () => { - expect(resolveCurrentWorkspaceLabel(null)).toBe("Current checkout"); - }); - - it("describes the active checkout as a worktree when one is attached", () => { - expect(resolveCurrentWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Current worktree"); - }); -}); - -describe("resolveLockedWorkspaceLabel", () => { - it("uses a shorter label for the main repo checkout", () => { - expect(resolveLockedWorkspaceLabel(null, "local")).toBe("Local checkout"); - }); - - it("uses a shorter label for an attached worktree", () => { - expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a", "worktree")).toBe( - "Worktree", - ); - }); - - it("describes a worktree that is still being created as a new worktree", () => { - expect(resolveLockedWorkspaceLabel(null, "worktree")).toBe("New worktree"); - }); -}); - describe("deriveLocalBranchNameFromRemoteRef", () => { - it("strips the remote prefix from a remote ref", () => { - expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo"); - }); - it("supports remote names that contain slashes", () => { expect(deriveLocalBranchNameFromRemoteRef("my-org/upstream/feature/demo")).toBe( "upstream/feature/demo", ); }); - - it("returns the original name when ref is malformed", () => { - expect(deriveLocalBranchNameFromRemoteRef("origin/")).toBe("origin/"); - expect(deriveLocalBranchNameFromRemoteRef("/feature/demo")).toBe("/feature/demo"); - }); }); describe("dedupeRemoteBranchesWithLocalMatches", () => { @@ -826,54 +294,6 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { ]); }); - it("keeps all entries when no local match exists for a remote ref", () => { - const input: VcsRef[] = [ - { - name: "feature/local", - current: false, - isDefault: false, - worktreePath: null, - }, - { - name: "origin/feature/remote-only", - isRemote: true, - remoteName: "origin", - current: false, - isDefault: false, - worktreePath: null, - }, - ]; - - expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ - "feature/local", - "origin/feature/remote-only", - ]); - }); - - it("keeps non-origin remote refs visible even when a matching local ref exists", () => { - const input: VcsRef[] = [ - { - name: "feature/demo", - current: false, - isDefault: false, - worktreePath: null, - }, - { - name: "my-org/upstream/feature/demo", - isRemote: true, - remoteName: "my-org/upstream", - current: false, - isDefault: false, - worktreePath: null, - }, - ]; - - expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ - "feature/demo", - "my-org/upstream/feature/demo", - ]); - }); - it("keeps non-origin remote refs visible when git tracks with first-slash local naming", () => { const input: VcsRef[] = [ { @@ -934,23 +354,6 @@ describe("resolveBranchSelectionTarget", () => { }); }); - it("checks out the default ref in the main repo when leaving a secondary worktree", () => { - expect( - resolveBranchSelectionTarget({ - activeProjectCwd: "/repo", - activeWorktreePath: "/repo/.t3/worktrees/feature-a", - refName: { - isDefault: true, - worktreePath: null, - }, - }), - ).toEqual({ - checkoutCwd: "/repo", - nextWorktreePath: null, - reuseExistingWorktree: false, - }); - }); - it("keeps checkout in the current worktree for non-default refs", () => { expect( resolveBranchSelectionTarget({ @@ -981,28 +384,6 @@ describe("shouldIncludeBranchPickerItem", () => { ).toBe(true); }); - it("keeps the synthetic create-ref item visible for arbitrary ref input", () => { - expect( - shouldIncludeBranchPickerItem({ - itemValue: "__create_new_branch__:feature/demo", - normalizedQuery: "feature/demo", - createBranchItemValue: "__create_new_branch__:feature/demo", - checkoutPullRequestItemValue: null, - }), - ).toBe(true); - }); - - it("still filters ordinary ref items by query text", () => { - expect( - shouldIncludeBranchPickerItem({ - itemValue: "main", - normalizedQuery: "gh pr checkout 1359", - createBranchItemValue: "__create_new_branch__:gh pr checkout 1359", - checkoutPullRequestItemValue: "__checkout_pull_request__:1359", - }), - ).toBe(false); - }); - it("surfaces an existing ref matching the sanitized query", () => { expect( shouldIncludeBranchPickerItem({ @@ -1013,43 +394,13 @@ describe("shouldIncludeBranchPickerItem", () => { }), ).toBe(true); }); - - it("surfaces a ref from a partial query containing a space", () => { - expect( - shouldIncludeBranchPickerItem({ - itemValue: "hello-world", - normalizedQuery: "hello w", - createBranchItemValue: null, - checkoutPullRequestItemValue: null, - }), - ).toBe(true); - }); - - it("excludes refs matching neither the raw nor the sanitized query", () => { - expect( - shouldIncludeBranchPickerItem({ - itemValue: "main", - normalizedQuery: "new branch", - createBranchItemValue: null, - checkoutPullRequestItemValue: null, - }), - ).toBe(false); - }); }); describe("sanitizeNewRefName", () => { - it("replaces a space with a dash", () => { - expect(sanitizeNewRefName("new branch")).toBe("new-branch"); - }); - it("collapses a run of whitespace into a single dash", () => { expect(sanitizeNewRefName("new branch")).toBe("new-branch"); }); - it("trims surrounding whitespace instead of turning it into dashes", () => { - expect(sanitizeNewRefName(" new branch ")).toBe("new-branch"); - }); - it("replaces tabs, which git rejects just like spaces", () => { expect(sanitizeNewRefName("new\tbranch")).toBe("new-branch"); }); @@ -1059,22 +410,6 @@ describe("sanitizeNewRefName", () => { expect(sanitizeNewRefName("new\u2009branch")).toBe("new\u2009branch"); }); - it("keeps slashes so nested ref names survive", () => { - expect(sanitizeNewRefName("feature/new thing")).toBe("feature/new-thing"); - }); - - it("preserves case because git ref names are case sensitive", () => { - expect(sanitizeNewRefName("Feature/New Thing")).toBe("Feature/New-Thing"); - }); - - it("leaves an already valid ref name untouched", () => { - expect(sanitizeNewRefName("feature/login")).toBe("feature/login"); - }); - - it("returns an empty string for whitespace-only input", () => { - expect(sanitizeNewRefName(" ")).toBe(""); - }); - it("does not collapse dashes the user typed", () => { expect(sanitizeNewRefName("new - branch")).toBe("new---branch"); expect(sanitizeNewRefName("foo--bar")).toBe("foo--bar"); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 926724abf086..ff3389ddca15 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -3,11 +3,10 @@ import type { EnvironmentMachineKind, VcsRef, ProjectId, - WorktreeStartRemote, WorktreeSubmodules, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import { deriveLocalBranchNameFromRemoteRef, sanitizeNewRefName } from "@t3tools/shared/git"; +import { sanitizeNewRefName } from "@t3tools/shared/git"; import { toSortableTimestamp } from "../lib/threadSort"; export { dedupeRemoteBranchesWithLocalMatches, @@ -23,6 +22,10 @@ export interface EnvironmentOption { machine: EnvironmentMachineKind; } +/** "Add to another machine" stays muted until hovered: easy to find, quieter than the machines. */ +export const ADD_TO_MACHINE_LABEL_CLASS = + "flex min-w-0 items-center gap-1.5 opacity-55 in-data-highlighted:opacity-100 in-data-popup-open:opacity-100"; + export const EnvMode = Schema.Literals(["local", "worktree"]); export type EnvMode = typeof EnvMode.Type; @@ -240,8 +243,7 @@ export function resolveBranchTriggerLabel(input: { effectiveEnvMode: EnvMode; resolvedActiveBranch: string | null; resolvedActiveBranchIsRemote: boolean | null; - resolvedActiveBranchRemoteName?: string | null; - startFromRemote: WorktreeStartRemote; + startFromOrigin: boolean; createNewBranch?: boolean; }): string { const { @@ -249,25 +251,18 @@ export function resolveBranchTriggerLabel(input: { effectiveEnvMode, resolvedActiveBranch, resolvedActiveBranchIsRemote, - resolvedActiveBranchRemoteName, - startFromRemote, + startFromOrigin, createNewBranch = true, } = input; if (!resolvedActiveBranch) { return "Select ref"; } + // Checking out an existing branch shows that branch, not a base. if (effectiveEnvMode === "worktree" && !activeWorktreePath && createNewBranch) { - // Until the ref is known to be local or remote, the selected name is shown as is. - if (startFromRemote === null || resolvedActiveBranchIsRemote === null) { - return `From ${resolvedActiveBranch}`; - } - const branchName = !resolvedActiveBranchIsRemote - ? resolvedActiveBranch - : resolvedActiveBranchRemoteName && - resolvedActiveBranch.startsWith(`${resolvedActiveBranchRemoteName}/`) - ? resolvedActiveBranch.slice(resolvedActiveBranchRemoteName.length + 1) - : deriveLocalBranchNameFromRemoteRef(resolvedActiveBranch); - return `From ${startFromRemote}/${branchName}`; + // Only the server can determine a local branch's configured remote. + return startFromOrigin && resolvedActiveBranchIsRemote === false + ? `From ${resolvedActiveBranch} (remote)` + : `From ${resolvedActiveBranch}`; } return resolvedActiveBranch; } diff --git a/apps/web/src/components/BranchToolbar.panel.browser.test.tsx b/apps/web/src/components/BranchToolbar.panel.browser.test.tsx deleted file mode 100644 index b3c3e6789cf5..000000000000 --- a/apps/web/src/components/BranchToolbar.panel.browser.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; -import { act, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; -import { page, userEvent } from "vite-plus/test/browser"; - -import "~/index.css"; - -const state = vi.hoisted(() => ({ - worktreePath: "/tmp/worktree" as string | null, - showContextMenu: vi.fn(), - writeTextToClipboard: vi.fn(), -})); - -vi.mock(import("../localApi"), async (original) => ({ - ...(await original()), - readLocalApi: (() => ({ contextMenu: { show: state.showContextMenu } })) as never, -})); -vi.mock(import("../hooks/useCopyToClipboard"), async (original) => ({ - ...(await original()), - writeTextToClipboard: state.writeTextToClipboard, -})); -vi.mock(import("./BranchToolbarBranchSelector"), async (original) => ({ - ...(await original()), - BranchToolbarBranchSelector: (() => null) as never, -})); -vi.mock(import("../state/entities"), async (original) => ({ - ...(await original()), - useQuietThreadShell: (() => ({ - environmentId: "local", - projectId: "project", - worktreePath: state.worktreePath, - })) as never, - useProject: (() => ({ workspaceRoot: "/tmp/project" })) as never, - useThreadShellsForProjectRefs: () => [], -})); - -import { BranchToolbar } from "./BranchToolbar"; - -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - state.worktreePath = "/tmp/worktree"; - state.showContextMenu.mockReset().mockResolvedValue("copy-path"); - state.writeTextToClipboard.mockReset().mockResolvedValue(true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -function render(props: Partial>) { - return act(async () => - root.render( - , - ), - ); -} - -it("keeps machine choices usable when the combined row's workspace is locked", async () => { - const onEnvironmentChange = vi.fn(); - await render({ - onEnvironmentChange, - availableEnvironments: ["local", "remote"].map((id) => ({ - environmentId: EnvironmentId.make(id), - projectId: ProjectId.make("project"), - label: id, - isPrimary: id === "local", - machine: "server", - })), - }); - const trigger = container.querySelector("button")!; - expect(trigger.textContent).toBe("local"); - expect(container.querySelectorAll("button")).toHaveLength(1); - - // A ctrl-click is a macOS secondary click; it must not open the menu. - await act(async () => { - trigger.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, ctrlKey: true })); - }); - expect(document.querySelector('[role="menu"]')).toBeNull(); - - await userEvent.click(trigger); - await expect - .element(page.getByRole("menuitemradio", { name: "New worktree" })) - .toHaveAttribute("aria-disabled", "true"); - await userEvent.click(page.getByRole("menuitemradio", { name: "remote" })); - expect(onEnvironmentChange).toHaveBeenCalledWith("remote"); -}); - -it.each([ - ["local", null, "/tmp/project"], - ["worktree", null, null], - ["worktree", "/tmp/worktree", "/tmp/worktree"], -] as const)( - "copies only an existing workspace path with mode %s and worktree %s", - async (envMode, worktreePath, copiedPath) => { - state.worktreePath = worktreePath; - await render({ envMode }); - const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); - await act(async () => { - container.querySelector('[aria-label="Run context"]')!.dispatchEvent(event); - }); - expect(event.defaultPrevented).toBe(copiedPath !== null); - if (copiedPath === null) { - expect(state.showContextMenu).not.toHaveBeenCalled(); - expect(state.writeTextToClipboard).not.toHaveBeenCalled(); - } else { - await vi.waitFor(() => - expect(state.writeTextToClipboard).toHaveBeenCalledWith(copiedPath, "workspace path"), - ); - } - }, -); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index bfc638d32cd5..73dd9aa5ee98 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,14 +1,16 @@ import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; +import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; import { readLocalApi } from "../localApi"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { ComposerContextLabel } from "./ComposerContextLabel"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import type { EnvironmentId, ThreadId, WorktreeStartRemote } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { ChevronDownIcon, FolderGit2Icon, FolderGitIcon, FolderIcon, + PlusIcon, ScaleIcon, } from "lucide-react"; import { @@ -25,8 +27,9 @@ import { import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { useProject, useQuietThreadShell, useThreadShellsForProjectRefs } from "../state/entities"; +import { useProject, useThreadShellsForProjectRefs } from "../state/entities"; import { + ADD_TO_MACHINE_LABEL_CLASS, type EnvMode, type EnvironmentOption, resolveContextStripLabelsCompact, @@ -42,6 +45,8 @@ import { type BranchToolbarBranchSelectorHandle, } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; +import { openAddProjectToMachine } from "./AddProjectToMachineDialog"; +import { type AddProjectTarget, useAddProjectTargets } from "../hooks/useAddProjectTargets"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; import { PreviousWorktreeItemContent } from "./PreviousWorktreeItemContent"; import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; @@ -54,10 +59,14 @@ import { Menu, MenuGroup, MenuGroupLabel, + MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator, + MenuSub, + MenuSubPopup, + MenuSubTrigger, MenuTrigger, } from "./ui/menu"; import { Separator } from "./ui/separator"; @@ -82,6 +91,9 @@ interface BranchToolbarProps { ref?: Ref; environmentId: EnvironmentId; threadId: ThreadId; + projectId: ProjectId; + worktreePath: string | null; + isServerThread: boolean; showGitControls: boolean; draftId?: DraftId; onEnvModeChange: (mode: EnvMode) => void; @@ -90,8 +102,8 @@ interface BranchToolbarProps { onActiveThreadBranchOverrideChange?: (branch: string | null) => void; createNewBranch: boolean; onCreateNewBranchChange?: ((createNewBranch: boolean) => void) | undefined; - startFromRemote: WorktreeStartRemote; - onStartFromRemoteChange: (startFromRemote: WorktreeStartRemote) => void; + startFromOrigin: boolean; + onStartFromOriginChange: (startFromOrigin: boolean) => void; autoEnvironmentLabel?: string | undefined; onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; @@ -115,13 +127,14 @@ function showWorkspaceContextMenu(event: ReactMouseEvent, workspacePath: string) }) .then((action) => { if (action !== "copy-path") return; - void writeTextToClipboard(workspacePath, "workspace path").then( + const clipboardPath = formatFilePathForClipboard(workspacePath); + void writeTextToClipboard(clipboardPath, "workspace path").then( (didCopy) => { if (didCopy) { toastManager.add({ type: "success", title: "Path copied", - description: workspacePath, + description: clipboardPath, }); } }, @@ -157,6 +170,8 @@ interface RunContextSelectorProps { previousWorktreeLabel: string | null; previousWorktreeBranch: string | null; onUsePreviousWorktree: () => void; + addTargets: readonly AddProjectTarget[]; + onAddToMachine: (environmentId: EnvironmentId) => void; } const RunContextSelector = memo(function RunContextSelector({ @@ -178,6 +193,8 @@ const RunContextSelector = memo(function RunContextSelector({ previousWorktreeLabel, previousWorktreeBranch, onUsePreviousWorktree, + addTargets, + onAddToMachine, }: RunContextSelectorProps) { const composerFloatingLayerProps = useComposerMenuProps(); const activeEnvironment = @@ -313,7 +330,8 @@ const RunContextSelector = memo(function RunContextSelector({ } {...(!isPanel ? composerFloatingLayerProps : {})} > - {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? ( + {availableEnvironments && + ((showEnvironmentPicker && onEnvironmentChange) || addTargets.length > 0) ? ( <> Run on @@ -322,7 +340,7 @@ const RunContextSelector = memo(function RunContextSelector({ onValueChange={(value) => value === "auto" ? onAutoEnvironment?.() - : onEnvironmentChange(value as EnvironmentId) + : onEnvironmentChange?.(value as EnvironmentId) } > {onAutoEnvironment && ( @@ -345,7 +363,9 @@ const RunContextSelector = memo(function RunContextSelector({ {availableEnvironments.map((env) => ( @@ -356,6 +376,29 @@ const RunContextSelector = memo(function RunContextSelector({ ))} + {addTargets.length > 0 ? ( + + + + + Add to another machine + + + + {addTargets.map((target) => ( + onAddToMachine(target.environmentId)} + > + + + {target.label} + + + ))} + + + ) : null} @@ -428,8 +471,13 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { const current = element; const compact = overflows; if (!current) return; - const available = current.clientWidth; - if (available === 0) return; + const stripWidth = current.clientWidth; + if (stripWidth === 0) return; + const stripStyle = getComputedStyle(current); + const available = + stripWidth - + (Number.parseFloat(stripStyle.paddingInlineStart) || 0) - + (Number.parseFloat(stripStyle.paddingInlineEnd) || 0); const contentWidth = (parent: Element): number => { const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; let width = 0; @@ -448,7 +496,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { } return width + gap * Math.max(0, counted - 1); }; - const stripGap = Number.parseFloat(getComputedStyle(current).columnGap) || 0; + const stripGap = Number.parseFloat(stripStyle.columnGap) || 0; let needed = 0; let groups = 0; for (const child of current.children) { @@ -566,6 +614,9 @@ export const BranchToolbar = memo(function BranchToolbar({ ref, environmentId, threadId, + projectId, + worktreePath, + isServerThread, showGitControls, draftId, onEnvModeChange, @@ -574,8 +625,8 @@ export const BranchToolbar = memo(function BranchToolbar({ onActiveThreadBranchOverrideChange, createNewBranch, onCreateNewBranchChange, - startFromRemote, - onStartFromRemoteChange, + startFromOrigin, + onStartFromOriginChange, autoEnvironmentLabel, onAutoEnvironment, envLocked, @@ -588,28 +639,16 @@ export const BranchToolbar = memo(function BranchToolbar({ }: BranchToolbarProps) { const branchSelectorRef = useRef(null); const threadRef = scopeThreadRef(environmentId, threadId); - const draftThread = useComposerDraftStore((store) => - draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), - ); - const serverThread = useQuietThreadShell(threadRef); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const activeProjectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) - : draftThread - ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; + // The composer retains resolved thread details while bootstrap cleanup can remove its shell. + const activeProjectRef = scopeProjectRef(environmentId, projectId); const activeProject = useProject(activeProjectRef); - const hasActiveThread = serverThread !== null || draftThread !== null; - const activeWorktreePath = forceNewWorktree - ? null - : (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null); + const activeWorktreePath = forceNewWorktree ? null : worktreePath; const effectiveEnvMode = forceNewWorktree ? "worktree" : envMode; - const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); + const envModeLocked = envLocked || (isServerThread && activeWorktreePath !== null); - const canUsePreviousWorktree = - draftThread !== null && serverThread === null && !envModeLocked && !forceNewWorktree; - const projectRefsForWorktreeLookup = - canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []; + const canUsePreviousWorktree = !isServerThread && !envModeLocked && !forceNewWorktree; + const projectRefsForWorktreeLookup = canUsePreviousWorktree ? [activeProjectRef] : []; const projectThreads = useThreadShellsForProjectRefs(projectRefsForWorktreeLookup); const previousWorktreeSeed = canUsePreviousWorktree ? resolvePreviousWorktreeSeed({ @@ -621,7 +660,7 @@ export const BranchToolbar = memo(function BranchToolbar({ ? resolvePreviousWorktreeLabel(previousWorktreeSeed) : null; const onUsePreviousWorktree = () => { - if (!previousWorktreeSeed || !activeProjectRef) return; + if (!previousWorktreeSeed) return; setDraftThreadContext(draftId ?? threadRef, { branch: previousWorktreeSeed.branch, worktreePath: previousWorktreeSeed.worktreePath, @@ -649,19 +688,24 @@ export const BranchToolbar = memo(function BranchToolbar({ ], ); - const showEnvironmentPicker = Boolean( - availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, - ); + const addTargets = useAddProjectTargets(activeProject, availableEnvironments); + const onAddToMachine = (targetEnvironmentId: EnvironmentId | null) => + openAddProjectToMachine({ projectRef: activeProjectRef, targetEnvironmentId }); + // A project on one machine still gets the picker when it can be added to another. + const hasMultipleEnvironments = (availableEnvironments?.length ?? 0) > 1 || addTargets.length > 0; + const showEnvironmentPicker = hasMultipleEnvironments && onEnvironmentChange !== undefined; const activeEnvironmentOption = availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null; + // Started threads get no handler but still show which machine they run on, + // matching the strip visibility ChatView computes from the same inputs. const showEnvironmentIndicator = shouldShowEnvironmentIndicator({ activeEnvironment: activeEnvironmentOption, - canPickEnvironment: showEnvironmentPicker, + canPickEnvironment: hasMultipleEnvironments, }); const [stripElement, setStripElement] = useState(null); const labelsOverflow = useLabelsOverflow(stripElement); - if (!hasActiveThread || !activeProject) return null; + if (!activeProject) return null; if (layout === "panel") { return ( @@ -686,6 +730,8 @@ export const BranchToolbar = memo(function BranchToolbar({ previousWorktreeLabel={previousWorktreeLabel} previousWorktreeBranch={previousWorktreeSeed?.branch ?? null} onUsePreviousWorktree={onUsePreviousWorktree} + addTargets={addTargets} + onAddToMachine={onAddToMachine} /> ) : null} {panelSection !== "workspace" ? ( @@ -702,8 +748,8 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} createNewBranch={createNewBranch} onCreateNewBranchChange={onCreateNewBranchChange} - startFromRemote={startFromRemote} - onStartFromRemoteChange={onStartFromRemoteChange} + startFromOrigin={startFromOrigin} + onStartFromOriginChange={onStartFromOriginChange} {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} /> @@ -743,6 +789,8 @@ export const BranchToolbar = memo(function BranchToolbar({ previousWorktreeLabel={previousWorktreeLabel} previousWorktreeBranch={previousWorktreeSeed?.branch ?? null} onUsePreviousWorktree={onUsePreviousWorktree} + addTargets={addTargets} + onAddToMachine={onAddToMachine} /> ) : null} @@ -762,7 +810,8 @@ export const BranchToolbar = memo(function BranchToolbar({ envLocked={envLocked} environmentId={environmentId} availableEnvironments={availableEnvironments} - {...(showEnvironmentPicker && onEnvironmentChange ? { onEnvironmentChange } : {})} + {...(showEnvironmentPicker ? { onEnvironmentChange } : {})} + {...(addTargets.length > 0 ? { onAddToMachine: () => onAddToMachine(null) } : {})} /> {showGitControls ? ( diff --git a/apps/web/src/components/BranchToolbarBranchSelector.browser.test.tsx b/apps/web/src/components/BranchToolbarBranchSelector.browser.test.tsx index e79930693de0..fca7d9a6d97c 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.browser.test.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.browser.test.tsx @@ -1,7 +1,7 @@ import { EnvironmentId, ProjectId, ThreadId, type VcsRef } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; const state = vi.hoisted(() => ({ serverThread: null as { @@ -120,11 +120,8 @@ import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; const environmentId = EnvironmentId.make("local"); const projectId = ProjectId.make("project"); const threadId = ThreadId.make("thread"); -let root: Root | null = null; -let container: HTMLDivElement | null = null; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function resetState() { vi.clearAllMocks(); state.serverThread = null; state.draftThread = null; @@ -133,39 +130,25 @@ beforeEach(() => { getDraftThreadByRef: (() => state.draftThread) as never, setDraftThreadContext: state.setDraftThreadContext, }); -}); - -afterEach(async () => { - await act(() => root?.unmount()); - root = null; - container?.remove(); - container = null; - vi.unstubAllGlobals(); -}); +} -async function render(options: { envLocked: boolean; createNewBranch?: boolean }) { - await act(() => { - const selector = ( - - ); - if (!root) { - container = document.body.appendChild(document.createElement("div")); - root = createRoot(container); - } - root.render(selector); - }); +function selector(options: { envLocked: boolean; createNewBranch?: boolean }) { + return ( + + ); } it("does not reset a launched worktree from its initial unprovisioned shell", async () => { + resetState(); state.serverThread = { id: threadId, environmentId, @@ -175,14 +158,14 @@ it("does not reset a launched worktree from its initial unprovisioned shell", as runtime: null, }; - await render({ envLocked: true }); + await using screen = await render(selector({ envLocked: true })); state.serverThread = { ...state.serverThread, branch: "t3/abcd1234", worktreePath: "/repo-worktrees/temp", }; - await render({ envLocked: true }); + await screen.rerender(selector({ envLocked: true })); expect(state.updateMetadata).not.toHaveBeenCalled(); expect(state.stopSession).not.toHaveBeenCalled(); @@ -190,6 +173,7 @@ it("does not reset a launched worktree from its initial unprovisioned shell", as }); it("clears an unavailable existing branch in an empty thread's pending selection", async () => { + resetState(); state.serverThread = { id: threadId, environmentId, @@ -199,7 +183,7 @@ it("clears an unavailable existing branch in an empty thread's pending selection runtime: null, }; - await render({ envLocked: false, createNewBranch: false }); + await using _screen = await render(selector({ envLocked: false, createNewBranch: false })); expect(state.updateMetadata).not.toHaveBeenCalled(); expect(state.stopSession).not.toHaveBeenCalled(); @@ -207,6 +191,7 @@ it("clears an unavailable existing branch in an empty thread's pending selection }); it("defaults an empty thread's pending worktree base without changing its workspace", async () => { + resetState(); state.serverThread = { id: threadId, environmentId, @@ -216,7 +201,7 @@ it("defaults an empty thread's pending worktree base without changing its worksp runtime: null, }; - await render({ envLocked: false }); + await using _screen = await render(selector({ envLocked: false })); expect(state.setBranchOverride).toHaveBeenCalledWith("main"); expect(state.updateMetadata).not.toHaveBeenCalled(); @@ -224,6 +209,7 @@ it("defaults an empty thread's pending worktree base without changing its worksp }); it("still defaults a new worktree draft to the repository's base branch", async () => { + resetState(); state.draftThread = { environmentId, projectId, @@ -233,7 +219,7 @@ it("still defaults a new worktree draft to the repository's base branch", async environmentSelection: "auto", }; - await render({ envLocked: false }); + await using _screen = await render(selector({ envLocked: false })); expect(state.setDraftThreadContext).toHaveBeenCalledWith( { environmentId, threadId }, @@ -249,6 +235,7 @@ it("still defaults a new worktree draft to the repository's base branch", async }); it("still clears an unavailable existing branch in a draft", async () => { + resetState(); state.draftThread = { environmentId, projectId, @@ -258,7 +245,7 @@ it("still clears an unavailable existing branch in a draft", async () => { environmentSelection: "auto", }; - await render({ envLocked: false, createNewBranch: false }); + await using _screen = await render(selector({ envLocked: false, createNewBranch: false })); expect(state.setDraftThreadContext).toHaveBeenCalledWith( { environmentId, threadId }, diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index c1cdec0a9edf..78027ad16f70 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -4,10 +4,7 @@ import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePull import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { useRightPanelStore } from "../rightPanelStore"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { - canCheckoutBranchInNewWorktree, - canStartWorktreeFromUpstream, -} from "@t3tools/client-runtime/state/vcs"; +import { canCheckoutBranchInNewWorktree } from "@t3tools/client-runtime/state/vcs"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -20,7 +17,6 @@ import { type EnvironmentId, type ThreadId, type VcsRef, - type WorktreeStartRemote, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { ChevronDownIcon, GitBranchIcon } from "lucide-react"; @@ -97,8 +93,8 @@ interface BranchToolbarBranchSelectorProps { createNewBranch: boolean; /** Omitted when the server cannot check out an existing branch, which hides the toggle. */ onCreateNewBranchChange?: ((createNewBranch: boolean) => void) | undefined; - startFromRemote: WorktreeStartRemote; - onStartFromRemoteChange: (startFromRemote: WorktreeStartRemote) => void; + startFromOrigin: boolean; + onStartFromOriginChange: (startFromOrigin: boolean) => void; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -121,8 +117,8 @@ export function BranchToolbarBranchSelector({ onActiveThreadBranchOverrideChange, createNewBranch: createNewBranchProp, onCreateNewBranchChange, - startFromRemote, - onStartFromRemoteChange, + startFromOrigin, + onStartFromOriginChange, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { @@ -574,8 +570,7 @@ export function BranchToolbarBranchSelector({ effectiveEnvMode, resolvedActiveBranch, resolvedActiveBranchIsRemote, - resolvedActiveBranchRemoteName: selectedBranchRef?.remoteName ?? null, - startFromRemote, + startFromOrigin, createNewBranch, }); @@ -727,13 +722,9 @@ export function BranchToolbarBranchSelector({ } : undefined } - remoteControl={ + originControl={ isSelectingWorktreeBase && createNewBranch - ? { - value: startFromRemote, - upstreamAvailable: canStartWorktreeFromUpstream(branchStatusQuery.data?.remoteNames), - onChange: onStartFromRemoteChange, - } + ? { checked: startFromOrigin, onCheckedChange: onStartFromOriginChange } : undefined } popupProps={{ diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 038f33f02e9b..b2f4d09f13f7 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -5,10 +5,10 @@ import { useAtomValue } from "@effect/atom-react"; import { shortcutLabelForCommand } from "../keybindings"; import { primaryServerKeybindingsAtom } from "../state/server"; import type { EnvironmentId } from "@t3tools/contracts"; -import { ScaleIcon } from "lucide-react"; +import { PlusIcon, ScaleIcon } from "lucide-react"; import { memo } from "react"; -import type { EnvironmentOption } from "./BranchToolbar.logic"; +import { ADD_TO_MACHINE_LABEL_CLASS, type EnvironmentOption } from "./BranchToolbar.logic"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { useComposerMenuProps } from "./chat/composerEventScope"; import { @@ -27,8 +27,14 @@ interface BranchToolbarEnvironmentSelectorProps { environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; + onAddToMachine?: (() => void) | undefined; } +// Action values cannot collide with server-minted environment IDs. +const AUTO_VALUE = "action:auto"; +const ADD_MACHINE_VALUE = "action:add-machine"; +const environmentValue = (id: EnvironmentId) => `env:${id}`; + export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ autoEnvironmentLabel, onAutoEnvironment, @@ -36,6 +42,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir environmentId, availableEnvironments, onEnvironmentChange, + onAddToMachine, }: BranchToolbarEnvironmentSelectorProps) { const composerFloatingLayerProps = useComposerMenuProps(); const shortcutLabel = shortcutLabelForCommand( @@ -47,12 +54,13 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir const environmentItems = [ ...(onAutoEnvironment - ? [{ value: "auto", label: autoEnvironmentLabel ?? "Auto balance" }] + ? [{ value: AUTO_VALUE, label: autoEnvironmentLabel ?? "Auto balance" }] : []), ...availableEnvironments.map((env) => ({ - value: env.environmentId, + value: environmentValue(env.environmentId), label: env.label, })), + ...(onAddToMachine ? [{ value: ADD_MACHINE_VALUE, label: "Add to another machine" }] : []), ]; // The static label carries the xs control's height (h-7 sm:h-6) as well as @@ -60,7 +68,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir // the glass seam joining it to the composer assumes a fixed strip height, so // a shorter label would drag the seam out of line whenever this label is the // only thing in the strip. - if (envLocked || onEnvironmentChange === undefined) { + if (envLocked || (onEnvironmentChange === undefined && onAddToMachine === undefined)) { const lockedRow = ( - value === "auto" ? onAutoEnvironment?.() : onEnvironmentChange(value as EnvironmentId) - } + value={autoEnvironmentLabel ? AUTO_VALUE : environmentValue(environmentId)} + onValueChange={(value) => { + if (value === ADD_MACHINE_VALUE) { + onAddToMachine?.(); + } else if (value === AUTO_VALUE) { + onAutoEnvironment?.(); + } else if (value?.startsWith("env:")) { + onEnvironmentChange?.(value.slice(4) as EnvironmentId); + } + }} items={environmentItems} > @@ -124,7 +138,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir Run on {onAutoEnvironment && ( { if (autoEnvironmentLabel) onAutoEnvironment?.(); }} @@ -136,13 +150,25 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir )} {availableEnvironments.map((env) => ( - + {env.label} ))} + {onAddToMachine ? ( + + + + + ) : null} diff --git a/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx b/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx deleted file mode 100644 index ef33e24e2100..000000000000 --- a/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { RegistryContext } from "@effect/atom-react"; -import { type AssetCreateUrlResult, EnvironmentId, ThreadId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import { AtomRegistry } from "effect/reactivity"; -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -const mint = vi.hoisted(() => vi.fn<() => Promise>()); -// Atoms derived from the server config run in the real registry over a missing config. -vi.mock(import("../state/server"), async (original) => { - const actual = await original(); - const { Atom } = await import("effect/reactivity"); - const noConfig = Atom.make(null); - return { - ...actual, - serverEnvironment: { ...actual.serverEnvironment, configValueAtom: (() => noConfig) as never }, - }; -}); -// Keep the real query runner and registry: refreshes during waiting requests are skipped. -vi.mock(import("../state/assets"), async (original) => { - const actual = await original(); - const { Atom } = await import("effect/reactivity"); - const family = Atom.family((_key: string) => - Atom.make(Effect.tryPromise(() => mint())).pipe(Atom.keepAlive), - ); - return { - ...actual, - assetEnvironment: { - ...actual.assetEnvironment, - createUrl: (target) => family(JSON.stringify(target)), - }, - }; -}); -vi.mock(import("../state/filesystem"), async (original) => ({ - ...(await original()), - useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }), -})); -vi.mock(import("../state/session"), async (original) => ({ - ...(await original()), - readEnvironmentScope: () => false, - useEnvironmentScope: () => false, - readPreparedConnection: (() => ({ httpBaseUrl: "https://host.test" })) as never, - usePreparedConnection: (() => ({ - _tag: "Some", - value: { httpBaseUrl: "https://host.test" }, - })) as never, -})); - -import ChatMarkdown from "./ChatMarkdown"; - -const roots: Root[] = []; -let registry: AtomRegistry.AtomRegistry; - -const imageUrl = (version: string) => - `data:image/svg+xml,#${version}`; -const assetResult = (version: string): AssetCreateUrlResult => ({ - relativeUrl: imageUrl(version), - expiresAt: 1, -}); - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - registry = AtomRegistry.make(); - mint.mockReset().mockResolvedValue(assetResult("updated")); -}); -afterEach(async () => { - for (const root of roots.splice(0)) await act(async () => root.unmount()); - registry.dispose(); - document.body.replaceChildren(); - vi.unstubAllGlobals(); -}); - -function createTestRoot() { - const container = document.body.appendChild(document.createElement("div")); - const root = createRoot(container); - roots.push(root); - return { - container, - render: (children: ReactNode) => - act(async () => { - root.render( - {children}, - ); - }), - }; -} - -it("opens host media through server authorization before the client grant loads", async () => { - mint.mockResolvedValue({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }); - const onImageExpand = vi.fn(); - const { container, render } = createTestRoot(); - await render( - , - ); - const link = container.querySelector('a[href="/tmp/image.png"]'); - expect(link).not.toBeNull(); - await act(async () => link!.click()); - await vi.waitFor(() => - expect(onImageExpand).toHaveBeenCalledWith( - expect.objectContaining({ - images: [expect.objectContaining({ src: "https://host.test/api/assets/image.png" })], - }), - ), - ); -}); - -const threadRef = { - environmentId: EnvironmentId.make("media-environment"), - threadId: ThreadId.make("media-thread"), -}; - -function markdown(workspaceMutationId?: string | null) { - return ( - - ); -} - -function renderedImage(container: HTMLElement) { - return container.querySelector('img[alt="Diagram"]')?.getAttribute("src"); -} - -it("ordinary chat keeps its embedded asset URL across renders and reopening", async () => { - const { container, render } = createTestRoot(); - await render(markdown()); - expect(renderedImage(container)).toBe(imageUrl("updated")); - mint.mockResolvedValue(assetResult("changed")); - - await render(markdown()); - await render(null); - await render(markdown()); - - expect(renderedImage(container)).toBe(imageUrl("updated")); - expect(mint).toHaveBeenCalledTimes(1); -}); - -it.each(["URL", "failure"] as const)( - "reopening a file preview with no workspace mutation replaces its cached %s", - async (cached) => { - if (cached === "failure") mint.mockRejectedValueOnce(new Error("Image missing")); - else mint.mockResolvedValueOnce(assetResult("stale")); - const { container, render } = createTestRoot(); - await render(markdown(null)); - expect(renderedImage(container)).toBe(cached === "URL" ? imageUrl("stale") : undefined); - await render(null); - - mint.mockResolvedValue(assetResult("updated")); - await render(markdown(null)); - - expect(renderedImage(container)).toBe(imageUrl("updated")); - expect(mint).toHaveBeenCalledTimes(2); - }, -); - -it.each(["initial", "refresh"] as const)( - "shows the latest workspace edit after a pending %s asset request settles", - async (request) => { - let resolvePending!: (value: AssetCreateUrlResult) => void; - const pending = new Promise((resolve) => { - resolvePending = resolve; - }); - const { container, render } = createTestRoot(); - if (request === "refresh") { - await render(markdown("m0")); - expect(renderedImage(container)).toBe(imageUrl("updated")); - } - - mint.mockReturnValueOnce(pending).mockResolvedValue(assetResult("latest")); - await render(markdown("m1")); - const callsBeforeEdits = mint.mock.calls.length; - await render(markdown("m2")); - await render(markdown("m3")); - expect(mint).toHaveBeenCalledTimes(callsBeforeEdits); - - await act(async () => { - resolvePending(assetResult("before-edits")); - await pending; - }); - - expect(renderedImage(container)).toBe(imageUrl("latest")); - expect(mint).toHaveBeenCalledTimes(callsBeforeEdits + 1); - await render(markdown("m3")); - expect(mint).toHaveBeenCalledTimes(callsBeforeEdits + 1); - }, -); diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index eac41c7eb0be..e30b81c5e5f9 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -1,15 +1,25 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { searchableMessageSegments } from "@t3tools/shared/threadFindText"; -import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; -import { act, type ReactElement } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; +import { type ReactElement } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; -import { MarkdownFindContext } from "./chat/markdownFindContext"; -import { useThreadFindHighlights } from "./chat/threadFindHighlights"; +import { act, render } from "../../test/render"; +import "../index.css"; + +import { filesystemEnvironment } from "../state/filesystem"; +import { useRightPanelStore } from "../rightPanelStore"; import { setMarkdownTaskChecked } from "./files/filePreviewMode"; +const { workspaceSearch, filesystemMetadata } = vi.hoisted(() => ({ + workspaceSearch: vi.fn(), + filesystemMetadata: vi.fn(), +})); +vi.mock(import("../state/use-atom-query-runner"), async (original) => ({ + ...(await original()), + useAtomQueryRunner: ((family: unknown) => + family === filesystemEnvironment.getMetadata ? filesystemMetadata : workspaceSearch) as never, +})); + // Atoms derived from the server config run in the real registry over a missing config. vi.mock(import("../state/server"), async (original) => { const actual = await original(); @@ -36,45 +46,43 @@ vi.mock(import("../remoteOpen"), async (original) => ({ ...(await original()), useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }) as never, })); -// A rendered diagram is an SVG; real Mermaid layout is not what these tests exercise. -vi.mock(import("./chat/MermaidDiagram"), async (original) => ({ - ...(await original()), - MermaidDiagram: () => , +const { renderDiagram } = vi.hoisted(() => ({ + renderDiagram: vi.fn<(source: string) => Promise | undefined>(), })); - -import ChatMarkdown from "./ChatMarkdown"; - -const roots: Root[] = []; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +// Control rendering latency without running Mermaid layout. +vi.mock(import("./chat/MermaidDiagram"), async (original) => { + const { use } = await import("react"); + return { + ...(await original()), + MermaidDiagram: ({ source }: { source: string }) => { + const pending = renderDiagram(source); + return ; + }, + }; }); -afterEach(async () => { - for (const root of roots.splice(0)) await act(async () => root.unmount()); - document.body.replaceChildren(); - CSS.highlights.clear(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); +const settingsOverrides = vi.hoisted(() => ({ mathRenderingEnabled: false })); +vi.mock(import("../hooks/useSettings"), async (original) => { + const actual = await original(); + type Settings = ReturnType; + function useClientSettings(select?: (settings: Settings) => T): T { + const settings = { ...actual.getClientSettings(), ...settingsOverrides }; + return select ? select(settings) : (settings as T); + } + return { ...actual, useClientSettings }; }); -async function render(element: ReactElement) { - const container = document.body.appendChild(document.createElement("div")); - const root = createRoot(container); - roots.push(root); - await act(async () => root.render(element)); - return { - container, - rerender: (next: ReactElement) => act(async () => root.render(next)), - }; -} +import ChatMarkdown from "./ChatMarkdown"; -async function renderText(text: string, props: Partial[0]> = {}) { - return (await render()).container; +function setup() { + settingsOverrides.mathRenderingEnabled = false; + renderDiagram.mockReset(); + workspaceSearch.mockReset().mockResolvedValue(AsyncResult.success({ entries: [] })); + filesystemMetadata.mockReset().mockResolvedValue(AsyncResult.success({ entries: [null] })); } -function linkTexts(container: HTMLElement) { - return [...container.querySelectorAll("a")].map((link) => link.textContent); +function renderText(text: string, props: Partial[0]> = {}) { + return render(); } function buttonsLabelled(container: HTMLElement, label: string) { @@ -83,105 +91,12 @@ function buttonsLabelled(container: HTMLElement, label: string) { ); } -function fileOptions(container: HTMLElement) { - return [...container.querySelectorAll("button")] - .map((button) => button.getAttribute("aria-label")) - .filter((label) => label?.startsWith("File options for ")); -} - -describe("ChatMarkdown bare anchor placeholders", () => { - it.each(["
", "", "", "", "", ""])( - "preserves unmatched %s without linking later blocks", - async (token) => { - const container = await renderText( - `- **"From ${token}"** appears in the header.\n\n- **Tests:** cover inheritance.\n\nThe deferred move continues on B.\n\nSee the link.`, - ); - expect(container.querySelector("strong")?.textContent).toBe(`"From ${token}"`); - expect(linkTexts(container)).toEqual(["the link"]); - expect(container.querySelectorAll("li")).toHaveLength(2); - expect([...container.querySelectorAll("p")].map((p) => p.textContent)).toContain( - "The deferred move continues on B.", - ); - }, - ); - - it.each([" ", "
more
\n"])( - "preserves a paired anchor closing in the raw block %s", - async (closing) => { - const container = await renderText(`See label\n\n${closing}\n\nfinish`); - expect(container.querySelector("p")?.textContent).toBe("See label"); - }, - ); - - it("preserves a paired anchor after comment-looking raw text", async () => { - const container = await renderText("See label -->"); - expect(container.querySelector("p")?.textContent).toBe("See label -->"); - }); - - it.each(["", '
more
', ''])( - "ignores apparent closing anchors inside %s", - async (html) => { - const container = await renderText(`Before .\n\n${html}\n\nAfter.`); - expect(container.querySelector("p")?.textContent).toBe("Before ."); - expect(container.querySelectorAll("a")).toHaveLength(0); - }, - ); - - it("preserves paired HTML anchors, details, markdown links, and inline code", async () => { - const container = await renderText( - 'Bare label, , ``, and [docs](https://example.com).\n\n
MoreDetails
', - ); - expect(linkTexts(container)).toEqual(["label", "", "docs"]); - expect(container.querySelector("code")?.textContent).toBe("
"); - expect(container.querySelector("[data-markdown-details]")?.textContent).toContain("More"); - }); -}); - -describe("ChatMarkdown context references", () => { - it("renders text and image references through the chip renderer, with readable fallback", async () => { - const text = - "See [Terminal output](t3-context://v1/terminal/term-1) and ![Error image](t3-context://v1/image/img-1)."; - const { container, rerender } = await render( - ( - - )} - />, - ); - expect([...container.querySelectorAll("button")].map((button) => button.textContent)).toEqual([ - "terminal: Terminal output", - "image: Error image", - ]); - expect(container.querySelectorAll("img, a")).toHaveLength(0); - await rerender(); - expect(container.textContent).toBe("See Terminal output and Error image."); - expect(container.querySelectorAll("img, a, button")).toHaveLength(0); - }); - - it("reads formatted context labels through nested markup instead of the context id", async () => { - const seen: Array = []; - await render( - { - seen.push(`${kind}: ${label}`); - return ; - }} - />, - ); - expect(new Set(seen)).toEqual(new Set(["terminal: Bold code"])); - }); -}); - describe("ChatMarkdown favicon privacy", () => { it("suppresses private link images while preserving public links across updates", async () => { + setup(); const markdown = (url: string) => ; - const { container, rerender } = await render(markdown("https://example.com")); + await using screen = await render(markdown("https://example.com")); + const { container, rerender } = screen; expect([...container.querySelectorAll("img")].map((image) => image.src)).toEqual([ "https://www.google.com/s2/favicons?domain=example.com&sz=32", ]); @@ -199,6 +114,7 @@ describe("ChatMarkdown favicon privacy", () => { describe("ChatMarkdown streaming", () => { it("runs only a complete single-line shell block after a click", async () => { + setup(); const onRunShellCommand = vi.fn(); const message = (text: string, isStreaming = false) => ( { onRunShellCommand={onRunShellCommand} /> ); - const { container, rerender } = await render(message("```bash\necho hello\n```", true)); + await using screen = await render(message("```bash\necho hello\n```", true)); + const { container, rerender } = screen; const runButtons = () => buttonsLabelled(container, "Run in terminal"); expect(runButtons()).toHaveLength(0); @@ -220,6 +137,8 @@ describe("ChatMarkdown streaming", () => { "~~~bash\necho tilde\n~~~", "> ```bash\n> echo quote\n> ```", "````bash\necho four\n````", + "- Command\n\n ```bash\n echo list\n ```", + "```bash\r\necho crlf\r\n```\r", ]) { await rerender(message(text)); expect(runButtons()).toHaveLength(1); @@ -242,113 +161,8 @@ describe("ChatMarkdown streaming", () => { } }); - it("does not retokenize completed lines when streaming finishes", async () => { - const highlighter = await getSyntaxHighlighterPromise("typescript"); - const highlight = vi.spyOn(highlighter, "codeToHast"); - const text = "```typescript\nconst completed = 1;\nconst current = 2;"; - const { rerender } = await render(); - await vi.waitFor(() => expect(highlight).toHaveBeenCalled()); - highlight.mockClear(); - await rerender(); - expect(highlight.mock.calls.every(([code]) => !code.includes("const completed"))).toBe(true); - }); - - it("recovers highlighting after a failed fence changes without resetting its controls", async () => { - const highlighter = await getSyntaxHighlighterPromise("text"); - const codeToHast = highlighter.codeToHast.bind(highlighter); - let fail = true; - vi.spyOn(highlighter, "codeToHast").mockImplementation((...args) => { - if (fail) throw new Error("Temporary highlighter failure"); - return codeToHast(...args); - }); - vi.spyOn(console, "error").mockImplementation(() => {}); - vi.spyOn(console, "warn").mockImplementation(() => {}); - - const { container, rerender } = await render( - , - ); - const codeBlock = container.querySelector('[data-language="text"]')!; - const initialWrap = codeBlock.dataset.wrap === "true"; - await act(async () => - buttonsLabelled(container, initialWrap ? "Disable line wrap" : "Wrap lines")[0]!.click(), - ); - expect(container.querySelectorAll(".chat-markdown-shiki")).toHaveLength(0); - - fail = false; - await rerender( - , - ); - await vi.waitFor(() => - expect(container.querySelectorAll(".chat-markdown-shiki")).toHaveLength(1), - ); - expect(container.querySelector('[data-language="text"]')).toBe(codeBlock); - expect(codeBlock.dataset.wrap).toBe(String(!initialWrap)); - }); - - it("preserves code controls and details without highlighting an unchanged fence again", async () => { - const highlighter = await getSyntaxHighlighterPromise("text"); - const highlight = vi.spyOn(highlighter, "codeToHast"); - const writeText = vi - .spyOn(navigator.clipboard, "writeText") - .mockImplementation(async () => undefined); - const text = [ - "```text", - "First code block", - "```", - "", - "
More", - "", - "Details content", - "", - "
", - "", - "Streaming reply", - ].join("\n"); - - const { container, rerender } = await render( - , - ); - const codeBlock = container.querySelector('[data-language="text"]')!; - const initialWrap = codeBlock.dataset.wrap === "true"; - await act(async () => { - buttonsLabelled(container, initialWrap ? "Disable line wrap" : "Wrap lines")[0]!.click(); - buttonsLabelled(container, "Copy code")[0]!.click(); - }); - await act(async () => - container.querySelector("[data-markdown-details-summary]")!.click(), - ); - const details = container.querySelector("[data-markdown-details]")!; - expect(details.dataset.markdownDetailsOpen).toBe("true"); - expect(container.textContent).toContain("Details content"); - expect(writeText).toHaveBeenCalledWith("First code block\n"); - expect(highlight).toHaveBeenCalledTimes(1); - - for (let index = 0; index < 10; index += 1) { - await rerender(); - } - - expect(highlight).toHaveBeenCalledTimes(1); - expect(container.querySelector('[data-language="text"]')).toBe(codeBlock); - expect(codeBlock.dataset.wrap).toBe(String(!initialWrap)); - expect(container.querySelector("[data-markdown-details]")).toBe(details); - expect(details.dataset.markdownDetailsOpen).toBe("true"); - await rerender( - , - ); - const copy = [ - ...buttonsLabelled(container, "Copied"), - ...buttonsLabelled(container, "Copy code"), - ][0]!; - await act(async () => copy.click()); - expect(writeText).toHaveBeenLastCalledWith("Updated code block\n"); - expect(highlight).toHaveBeenCalledTimes(2); - }); - it("edits the current task text and marker after reusing a renderer", async () => { + setup(); let editedText: string | undefined; let rerender: (next: ReactElement) => Promise = async () => {}; const message = (text: string): ReactElement => ( @@ -361,7 +175,7 @@ describe("ChatMarkdown streaming", () => { }} /> ); - const rendered = await render(message("- [ ] First\n- [ ] Second")); + await using rendered = await render(message("- [ ] First\n- [ ] Second")); rerender = rendered.rerender; const { container } = rendered; const checkboxes = () => container.querySelectorAll('input[type="checkbox"]'); @@ -375,210 +189,55 @@ describe("ChatMarkdown streaming", () => { }); }); -describe("ChatMarkdown skill chips", () => { - it("updates digit-leading skill labels when discovered skills change", async () => { - const text = "Use $2spec with a $20k budget."; - const { container, rerender } = await render(); - expect(container.textContent).not.toContain("2Spec"); - - await rerender( - , - ); - expect(container.textContent).toContain("2Spec"); - expect(container.textContent).toContain("$20k budget"); - expect(container.textContent).not.toContain("MoneySkill"); - - await rerender(); - expect(container.textContent).not.toContain("2Spec"); - }); -}); - -describe("ChatMarkdown file citations", () => { - it.each([true, false])( - "renders Codex file citations as file chips with parseRawHtml=%s", - async (parseRawHtml) => { - const container = await renderText( - 'Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx" purpose="output"}.', - { lineBreaks: !parseRawHtml, parseRawHtml }, - ); - expect(container.textContent).not.toContain("codex-file-citation"); - expect(fileOptions(container)).toEqual(["File options for report.xlsx"]); - }, - ); - - it("leaves an unfinished streaming citation visible until it is complete", async () => { - const container = await renderText( - 'Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx"', - { isStreaming: true }, - ); - expect(container.textContent).toContain(":codex-file-citation"); - expect(fileOptions(container)).toEqual([]); - }); - - it.each([ - ':codex-file-citation{purpose="output"}', - ':codex-file-citation-extra{path="/tmp/project/outputs/report.xlsx"}', - 'Example: \\:codex-file-citation{path="/tmp/project/outputs/report.xlsx"}', - ])("leaves %s literal", async (text) => { - const container = await renderText(text); - expect(container.textContent).toContain(text.replace("\\", "")); - expect(fileOptions(container)).toEqual([]); - }); - - it("preserves Codex file citation examples inside code", async () => { - const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'; - const container = await renderText( - `Example: \`${directive}\`\n\n\`\`\`text\n${directive}\n\`\`\``, - ); - expect(container.textContent?.match(/:codex-file-citation/g)).toHaveLength(2); - expect(fileOptions(container)).toEqual([]); - }); - - it("does not create a nested link for citations inside link text", async () => { - const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'; - const container = await renderText(`[See ${directive}](https://example.com)`); - expect(container.textContent).toContain("codex-file-citation"); - expect(fileOptions(container)).toEqual([]); - }); - - it("renders file citations created by over-indented list recovery", async () => { - const container = await renderText( - '- Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx"}', - ); - expect(container.querySelector("pre")).toBeNull(); - expect(container.querySelector("li")?.textContent).toContain("Created "); - expect(fileOptions(container)).toEqual(["File options for report.xlsx"]); - }); - - it("disambiguates Codex citations with the same basename", async () => { - const container = await renderText( - 'Changed :codex-file-citation{path="/tmp/project/src/index.ts"} and :codex-file-citation{path="/tmp/project/test/index.ts"}.', - ); - expect(container.textContent).toContain("index.ts · project/src"); - expect(container.textContent).toContain("index.ts · project/test"); - }); - - it("preserves rejected citations created by over-indented list recovery", async () => { - const malformed = await renderText( - 'Leading text before list.\n\n- Bad :codex-file-citation{purpose="output"}', - ); - expect(malformed.querySelector("li")?.textContent).toBe( - 'Bad :codex-file-citation{purpose="output"}', - ); - const nestedLink = await renderText( - 'Leading text before list.\n\n- [Bad :codex-file-citation{path="/tmp/project/report.xlsx"}](https://example.com)', - ); - expect(nestedLink.textContent).toContain( - 'Bad :codex-file-citation{path="/tmp/project/report.xlsx"}', - ); - }); -}); - -describe("ChatMarkdown Codex follow-up suggestions", () => { - it.each([true, false])("renders the label with parseRawHtml=%s", async (parseRawHtml) => { - const container = await renderText( - 'Done.\n\n- :codex-followup[Prepare print version]{prompt="Prepare the document for printing."}', - { lineBreaks: !parseRawHtml, parseRawHtml }, - ); - expect(container.textContent).toContain("Prepare print version"); - expect(container.textContent).not.toContain("codex-followup"); - expect(container.textContent).not.toContain("Prepare the document for printing."); - }); +describe("ChatMarkdown workspace file opens", () => { + const threadRef = { + environmentId: EnvironmentId.make("workspace"), + threadId: ThreadId.make("thread"), + }; - it("keeps the label of a malformed follow-up created by over-indented list recovery", async () => { - const container = await renderText("- Next :codex-followup[Prepare print version]"); - expect(container.querySelector("li")?.textContent).toBe( - "Next :codex-followup[Prepare print version]", + it("keeps a newer absolute file open when an earlier repo-relative lookup finishes", async () => { + setup(); + const result = AsyncResult.success({ + entries: [{ path: "notes/a.md", kind: "file" }], + truncated: false, + }); + let resolvePending!: (value: typeof result) => void; + const pending = new Promise((resolve) => { + resolvePending = resolve; + }); + workspaceSearch.mockReturnValueOnce(pending); + using openFile = vi + .spyOn(useRightPanelStore.getState(), "openFile") + .mockImplementation(() => {}); + await using screen = await renderText( + "[Notes](physics/notes/a.md) and [Other](/elsewhere/b.md:9)", + { + cwd: "/school/physics", + threadRef, + }, ); - }); -}); - -const ARTIFACT_TEMPLATE_DIRECTIVE = - '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}'; - -describe("ChatMarkdown artifact-template cards", () => { - it.each([true, false])( - "offers the Codex template from its card with parseRawHtml=%s", - async (parseRawHtml) => { - const onUseArtifactTemplate = vi.fn(); - const container = await renderText(ARTIFACT_TEMPLATE_DIRECTIVE, { - parseRawHtml, - onUseArtifactTemplate, - }); - expect(container.textContent).not.toContain("::artifact-template"); - expect(container.textContent).toContain("Hello World"); - expect(container.textContent).toContain("Document template"); - expect(container.querySelector("p div")).toBeNull(); - const use = [...container.querySelectorAll("button")].find( - (button) => button.textContent === "Use template", - )!; - await act(async () => use.click()); - expect(onUseArtifactTemplate).toHaveBeenCalledWith( - expect.objectContaining({ - skillName: "artifact-template-hello-world", - displayName: "Hello World", - }), - ); - }, - ); - - it("renders a passive card outside a composer-backed timeline", async () => { - const container = await renderText(ARTIFACT_TEMPLATE_DIRECTIVE); - expect(container.textContent).toContain("Hello World"); - expect(container.textContent).not.toContain("Use template"); - }); + const { container } = screen; + const [notes, other] = container.querySelectorAll("a"); + await act(async () => notes!.click()); + expect(workspaceSearch).toHaveBeenCalledOnce(); + expect(openFile).not.toHaveBeenCalled(); - it.each([ - '::artifact-template{skill_name="artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}', - ARTIFACT_TEMPLATE_DIRECTIVE.slice(0, -1), - `\\${ARTIFACT_TEMPLATE_DIRECTIVE}`, - ARTIFACT_TEMPLATE_DIRECTIVE.replace("::artifact-template", "::artifact-template-extra"), - ])("leaves %s literal", async (text) => { - const container = await renderText(text); - expect(container.textContent).toContain("::artifact-template"); - expect(container.textContent).not.toContain("Document template"); - }); + await act(async () => other!.click()); + await act(async () => resolvePending(result)); - it("preserves artifact-template examples inside code", async () => { - const container = await renderText( - `\`${ARTIFACT_TEMPLATE_DIRECTIVE}\`\n\n\`\`\`text\n${ARTIFACT_TEMPLATE_DIRECTIVE}\n\`\`\``, - ); - expect(container.textContent?.match(/::artifact-template/g)).toHaveLength(2); - expect(container.textContent).not.toContain("Document template"); + expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, "/elsewhere/b.md", 9); + expect(workspaceSearch).toHaveBeenCalledOnce(); }); }); describe("ChatMarkdown headings", () => { - it("exposes headings below the host heading without changing their tags", async () => { - const container = await renderText("# Top\n\n## Section\n\n###### Fine print", { - headingLevelOffset: 3, - }); - expect( - [...container.querySelectorAll("h1, h2, h6")].map((heading) => [ - heading.tagName, - heading.getAttribute("aria-level"), - ]), - ).toEqual([ - ["H1", "4"], - ["H2", "5"], - ["H6", "6"], - ]); - const flat = await renderText("# Top"); - expect(flat.querySelector("h1")?.hasAttribute("aria-level")).toBe(false); - }); - it("never gives two headings the same id, even when a suffix matches another heading", async () => { - const container = await renderText( + setup(); + await using screen = await renderText( '## Setup\n\n## Setup\n\n## Setup-1\n\n

Pinned

\n\n## Install\n\n## Install', { parseRawHtml: true }, ); + const { container } = screen; expect([...container.querySelectorAll("h2")].map((heading) => heading.id)).toEqual([ "user-content-setup", "user-content-setup-1", @@ -604,249 +263,17 @@ describe("ChatMarkdown Windows file links", () => { chip.getAttribute("href"), ); - it.each([ - ["drive paths", "[Open](C:/Users/shawn/project/src/main.ts)"], - ["backslashes", String.raw`[Open](C:\Users\shawn\project\src\main.ts)`], - ["reference links", "[Open][source]\n\n[source]: C:/Users/shawn/project/src/main.ts"], - ])("resolves %s to the workspace file", async (_, text) => { - for (const parseRawHtml of [true, false]) { - const container = await renderWindows(text, parseRawHtml); - expect(fileHrefs(container)).toEqual(["C:/Users/shawn/project/src/main.ts"]); - } - }); - - it("keeps backslashes CommonMark would read as escapes", async () => { - for (const parseRawHtml of [true, false]) { - const container = await renderWindows( - String.raw`[settings](C:\Users\shawn\.claude\settings.json)`, - parseRawHtml, - ); - expect(fileHrefs(container)).toEqual(["C:/Users/shawn/.claude/settings.json"]); - } - }); - - it("distinguishes same-named backslash paths but not the same file twice", async () => { - for (const parseRawHtml of [true, false]) { - const distinct = await renderWindows( - String.raw`[Source](C:\Users\shawn\project\src\index.ts) and [Test](C:\Users\shawn\project\test\index.ts)`, - parseRawHtml, - ); - expect(distinct.textContent).toContain("index.ts · project/src"); - expect(distinct.textContent).toContain("index.ts · project/test"); - const path = String.raw`C:\Users\shawn\project\src\main.ts`; - const same = await renderWindows(`[Source](${path}) and \`${path}\``, parseRawHtml); - expect(fileHrefs(same)).toHaveLength(2); - expect(same.textContent).not.toContain("main.ts ·"); - } - }); - it("still rejects unsafe schemes", async () => { + setup(); for (const parseRawHtml of [true, false]) { - const container = await renderWindows( + await using screen = await renderWindows( "[unsafe](javascript:alert(1)) and [unknown](d:alert(1))", parseRawHtml, ); + const { container } = screen; expect(container.innerHTML).not.toContain("javascript:"); expect(container.innerHTML).not.toContain("d:alert"); expect(fileHrefs(container)).toEqual([]); } }); }); - -describe("ChatMarkdown in-page links", () => { - it.each([true, false])( - "scrolls a table-of-contents link to its heading without touching the URL (parseRawHtml=%s)", - async (parseRawHtml) => { - const scrollIntoView = vi - .spyOn(HTMLElement.prototype, "scrollIntoView") - .mockImplementation(() => {}); - const href = window.location.href; - const container = await renderText( - "- [Operating model](#1-operating-model)\n- [Missing](#nowhere)\n\n## 1. Operating model\n\n## 1. Operating model", - { parseRawHtml }, - ); - const headings = [...container.querySelectorAll("h2")]; - expect(headings.map((heading) => heading.id)).toEqual([ - "user-content-1-operating-model", - "user-content-1-operating-model-1", - ]); - const [tocLink, missingLink] = [...container.querySelectorAll("a")]; - await act(async () => tocLink!.click()); - expect(scrollIntoView.mock.contexts).toEqual([headings[0]]); - await act(async () => missingLink!.click()); - expect(scrollIntoView).toHaveBeenCalledTimes(1); - expect(window.location.href).toBe(href); - }, - ); -}); - -describe("ChatMarkdown thread find", () => { - function FindProbe(props: { - container: HTMLElement; - query: string; - activeOccurrence?: number; - children: ReactElement; - }) { - useThreadFindHighlights({ - container: props.container, - query: props.query, - activeRowId: "row", - activeOccurrence: props.activeOccurrence ?? 0, - onActiveRange: () => {}, - }); - return ( -
-
- {props.children} -
-
- ); - } - const frame = () => - act(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); - const activeHighlights = () => - [...(CSS.highlights.get("t3-thread-find-active") ?? [])].map((range) => - (range as Range).toString(), - ); - - it("opens a disclosure only when find selects a match inside it", async () => { - const container = document.body.appendChild(document.createElement("div")); - const root = createRoot(container); - roots.push(root); - const markdown = ( - Unrelated

nothing here

", - "
Outer
Inner

needle

", - ].join("\n\n")} - /> - ); - const show = (query: string, activeOccurrence: number) => - act(() => - root.render( - - {markdown} - , - ), - ); - const openStates = () => - [...container.querySelectorAll("[data-markdown-details-open]")].map( - (node) => node.dataset.markdownDetailsOpen, - ); - - await show("", 0); - expect(container.textContent).not.toContain("nothing here"); - await show("needle", 0); - await frame(); - expect(openStates()).toEqual(["false", "false", "false"]); - expect(container.textContent).toContain("nothing here"); - expect(activeHighlights()).toEqual(["needle"]); - expect(CSS.highlights.get("t3-thread-find")?.size ?? 0).toBe(0); - - await show("needle", 1); - await frame(); - await frame(); - expect(openStates()).toEqual(["false", "true", "true"]); - expect(CSS.highlights.get("t3-thread-find-active")?.size).toBe(1); - }); - - it("keeps Mermaid diagrams rendered until find selects a match in their source", async () => { - const container = document.body.appendChild(document.createElement("div")); - const root = createRoot(container); - roots.push(root); - const diagram = "```mermaid\ngraph TD; Alpha-->Beta\n```"; - const show = (query: string) => - act(() => - root.render( - - - , - ), - ); - const diagrams = () => container.querySelectorAll('svg[aria-label="Diagram"]').length; - - await show("Needle"); - await frame(); - expect(diagrams()).toBe(2); - await show("Alpha"); - await frame(); - await frame(); - expect(diagrams()).toBe(1); - await vi.waitFor(() => expect(activeHighlights()).toEqual(["Alpha"])); - }); - - it.each([ - { - text: "```mermaid\ngraph TD; SearchSourceAlpha-->B\n```", - query: "SearchSourceAlpha", - count: 1, - }, - { - text: "★ Insight ─────\nfirst line\nsecond line", - query: "first line second", - count: 0, - lineBreaks: true, - }, - { text: '```ts title="src/needle.ts"\nconst a = 1;\n```', query: "needle", count: 0 }, - { text: "```weirdlang\nconst a = 1;\n```", query: "weirdlang", count: 0 }, - { text: "Use $test-t3-app now", query: "T3 App Testing", count: 1 }, - { text: "`/tmp/file.ts:42`", query: "file.ts · L42", count: 1, user: true, lineBreaks: true }, - { text: "> [!NOTE]\n> Searchable alert", query: "Searchable alert", count: 1 }, - { - text: "
Folded

Hidden needle

", - query: "Hidden needle", - count: 1, - }, - { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Hello World", count: 1, withTemplate: true }, - { - text: ARTIFACT_TEMPLATE_DIRECTIVE, - query: "Document template", - count: 1, - withTemplate: true, - }, - { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "World Document", count: 0, withTemplate: true }, - { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Use template", count: 0, withTemplate: true }, - ])( - "highlights the indexed occurrences of $query in $text", - async ({ text, query, count, lineBreaks, user, withTemplate }) => { - const skills = [{ name: "test-t3-app", displayName: "T3 App Testing" }]; - const container = document.body.appendChild(document.createElement("div")); - const root = createRoot(container); - roots.push(root); - await act(() => - root.render( - - undefined : undefined} - /> - , - ), - ); - await frame(); - // Lazy blocks such as Mermaid can swap their source text after the first frame, - // emptying a range for a moment, so wait for the highlights to settle. - await vi.waitFor(() => { - const ranges = [...CSS.highlights.values()].flatMap((value) => [...value]); - expect(ranges.map((range) => (range as Range).toString())).toEqual( - Array.from({ length: count }, () => query), - ); - }); - const segments = - searchableMessageSegments( - { role: user ? "user" : "assistant", text, streaming: false }, - undefined, - skills, - ) ?? []; - expect( - segments.reduce((sum, segment) => sum + countThreadSearchOccurrences(segment, query), 0), - ).toBe(count); - }, - ); -}); diff --git a/apps/web/src/components/ChatMarkdown.logic.test.ts b/apps/web/src/components/ChatMarkdown.logic.test.ts deleted file mode 100644 index e93edec03746..000000000000 --- a/apps/web/src/components/ChatMarkdown.logic.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - canUseMarkdownFileShellActions, - shouldUseMarkdownFileBrowserPrimaryAction, -} from "./ChatMarkdown"; - -describe("canUseMarkdownFileShellActions", () => { - const environmentId = EnvironmentId.make("environment-1"); - - it("allows editor and file manager actions only for resolved local environments", () => { - expect(canUseMarkdownFileShellActions(environmentId, "local-exec", true)).toBe(true); - expect(canUseMarkdownFileShellActions(environmentId, "local-exec", false)).toBe(false); - expect(canUseMarkdownFileShellActions(environmentId, "remote-links", true)).toBe(false); - expect(canUseMarkdownFileShellActions(environmentId, "remote-unavailable", true)).toBe(false); - expect(canUseMarkdownFileShellActions(null, "local-exec", true)).toBe(false); - }); -}); - -describe("shouldUseMarkdownFileBrowserPrimaryAction", () => { - const html = { iconPath: "/tmp/report.html", canOpenInBrowser: true }; - - it("uses the browser only when it is the sole action, except for PDFs", () => { - expect( - shouldUseMarkdownFileBrowserPrimaryAction({ - ...html, - canOpenInEditor: false, - canOpenInPanel: false, - }), - ).toBe(true); - expect( - shouldUseMarkdownFileBrowserPrimaryAction({ - ...html, - canOpenInEditor: true, - canOpenInPanel: false, - }), - ).toBe(false); - expect( - shouldUseMarkdownFileBrowserPrimaryAction({ - ...html, - canOpenInEditor: false, - canOpenInPanel: true, - }), - ).toBe(false); - expect( - shouldUseMarkdownFileBrowserPrimaryAction({ - iconPath: "/tmp/report.pdf", - canOpenInEditor: true, - canOpenInBrowser: true, - canOpenInPanel: true, - }), - ).toBe(true); - }); -}); diff --git a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx index 51d380a4e354..de274fdd433b 100644 --- a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx @@ -8,10 +8,9 @@ import { type ThreadLinkedPullRequest, } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../test/render"; import { useRightPanelStore } from "../rightPanelStore"; const state = vi.hoisted(() => ({ @@ -130,10 +129,9 @@ const serverConfig = { shellRevealInFileManagerKind: "xdg-open", environment: { platform: { os: "linux" }, capabilities: { threadPullRequestLinking: true } }, }; -const roots: Root[] = []; -let container: HTMLDivElement; -beforeEach(() => { +function setup() { + using stack = new DisposableStack(); state.allowed = true; state.listeners.clear(); state.linkedPullRequest = null; @@ -144,36 +142,35 @@ beforeEach(() => { state.openFile.mockReset(); state.openExternal.mockReset().mockResolvedValue(undefined); state.copy.mockReset().mockResolvedValue(undefined); + const { openFile } = useRightPanelStore.getState(); useRightPanelStore.setState({ openFile: state.openFile }); - vi.spyOn(navigator.clipboard, "writeText").mockImplementation(state.copy); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); -}); - -afterEach(async () => { - for (const root of roots.splice(0)) await act(async () => root.unmount()); - document.body.replaceChildren(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -async function renderMarkdown(text: string) { - container = document.body.appendChild(document.createElement("div")); - const root = createRoot(container); - roots.push(root); - await act(async () => - root.render(), + stack.defer(() => useRightPanelStore.setState({ openFile })); + stack.use(vi.spyOn(navigator.clipboard, "writeText").mockImplementation(state.copy)); + stack.use( + vi.spyOn(navigator.clipboard, "write").mockImplementation(async (items) => { + const item = items[0]!; + await state.copy(await (await item.getType("text/plain")).text()); + }), ); + return stack.move(); } -function anchor() { - return container.querySelector("a")!; -} - -async function openContextMenu() { - await act(async () => { - anchor().dispatchEvent( - new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }), - ); +async function renderMarkdown(text: string) { + const screen = await render(); + const anchor = () => screen.container.querySelector("a")!; + return Object.assign(screen, { + anchor, + openContextMenu: () => + act(async () => { + anchor().dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 10, + clientY: 10, + }), + ); + }), }); } @@ -189,20 +186,22 @@ async function setAllowed(allowed: boolean) { } it("removes host actions after revocation while keeping file preview and copying", async () => { - await renderMarkdown("[Readme](docs/readme.md)"); - await openContextMenu(); + using _setup = setup(); + await using screen = await renderMarkdown("[Readme](docs/readme.md)"); + await screen.openContextMenu(); expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]); await setAllowed(false); state.choose.mockResolvedValue("copy-full"); - await openContextMenu(); + await screen.openContextMenu(); expect(offeredActions()).toEqual(["copy-relative", "copy-full"]); await vi.waitFor(() => expect(state.copy).toHaveBeenCalledWith("/work/docs/readme.md")); - await act(async () => anchor().click()); + await act(async () => screen.anchor().click()); expect(state.openFile).toHaveBeenCalledWith(threadRef, "docs/readme.md", undefined); expect(state.openEditor).not.toHaveBeenCalled(); }); it("does not launch an editor after revocation during a native context menu", async () => { + using _setup = setup(); let choose: (action: string) => void = () => { throw new Error("Menu not opened"); }; @@ -212,8 +211,8 @@ it("does not launch an editor after revocation during a native context menu", as choose = resolve; }), ); - await renderMarkdown("[Readme](docs/readme.md)"); - await openContextMenu(); + await using screen = await renderMarkdown("[Readme](docs/readme.md)"); + await screen.openContextMenu(); expect(offeredActions()).toContain("open"); await act(async () => { state.allowed = false; @@ -223,6 +222,7 @@ it("does not launch an editor after revocation during a native context menu", as }); it("does not reveal a file after revocation during its workspace lookup", async () => { + using _setup = setup(); let finishLookup: () => void = () => { throw new Error("Lookup not started"); }; @@ -233,8 +233,8 @@ it("does not reveal a file after revocation during its workspace lookup", async }), ); state.choose.mockResolvedValue("reveal"); - await renderMarkdown("[Readme](readme.md)"); - await openContextMenu(); + await using screen = await renderMarkdown("[Readme](readme.md)"); + await screen.openContextMenu(); expect(state.search).toHaveBeenCalledOnce(); await act(async () => { state.allowed = false; @@ -243,23 +243,10 @@ it("does not reveal a file after revocation during its workspace lookup", async expect(state.openEditor).not.toHaveBeenCalled(); }); -it.each([false, true])( - "keeps PR links usable without offering metadata edits (linked=%s)", - async (linked) => { - state.allowed = false; - state.linkedPullRequest = linked ? linkedPullRequest : null; - state.choose.mockResolvedValue("open-external"); - await renderMarkdown(`[Review](${linkedPullRequest.url})`); - await openContextMenu(); - expect(offeredActions()).toEqual(["open-external", "copy-link"]); - await vi.waitFor(() => expect(state.openExternal).toHaveBeenCalledWith(linkedPullRequest.url)); - expect(state.updateMetadata).not.toHaveBeenCalled(); - }, -); - it.each(["link-to-thread", "unlink-from-thread"])( "rechecks %s after a native context menu returns", async (action) => { + using _setup = setup(); state.linkedPullRequest = action === "unlink-from-thread" ? linkedPullRequest : null; let choose: (action: string) => void = () => { throw new Error("Menu not opened"); @@ -270,8 +257,8 @@ it.each(["link-to-thread", "unlink-from-thread"])( choose = resolve; }), ); - await renderMarkdown(`[Review](${linkedPullRequest.url})`); - await openContextMenu(); + await using screen = await renderMarkdown(`[Review](${linkedPullRequest.url})`); + await screen.openContextMenu(); expect(offeredActions()).toContain(action); await act(async () => { state.allowed = false; @@ -281,7 +268,7 @@ it.each(["link-to-thread", "unlink-from-thread"])( await setAllowed(true); state.choose.mockResolvedValue(action); - await openContextMenu(); + await screen.openContextMenu(); await vi.waitFor(() => expect(state.updateMetadata).toHaveBeenCalledExactlyOnceWith({ environmentId: threadRef.environmentId, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9ec6ec2db051..91acb81d1730 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,9 +1,4 @@ -import { MarkdownFindContext, findRevealRef } from "./chat/markdownFindContext"; -import { - buildFileLinkParentSuffixByPath, - fileLinkLabel, - resolvePathLinkTarget, -} from "@t3tools/shared/fileLinks"; +import { buildFileLinkParentSuffixByPath, fileLinkLabel } from "@t3tools/shared/fileLinks"; import { isWindowsDrivePathHref, normalizeMarkdownLinkDestination, @@ -11,7 +6,7 @@ import { extractMarkdownLinkHrefs, inlineCodeFilePathCandidate, } from "@t3tools/shared/markdownLinks"; -import { isAbsolutePath } from "@t3tools/shared/path"; +import { isHostPath, resolveWorkspaceFilePath } from "@t3tools/shared/path"; import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { @@ -25,8 +20,6 @@ import { encodeComposerContextClipboardHtml, } from "@t3tools/shared/composerContextClipboard"; import { - ChevronRightIcon, - CodeIcon, FileSpreadsheetIcon, FileTextIcon, GlobeIcon, @@ -37,18 +30,14 @@ import { MessageSquareIcon, MessageSquareWarningIcon, OctagonAlertIcon, - PlayIcon, PresentationIcon, SparklesIcon, TriangleAlertIcon, - WorkflowIcon, - WrapTextIcon, type LucideIcon, } from "lucide-react"; -import { Check, Copy, Maximize2, Minimize2 } from "lucide"; import { + AssetWorkspaceAssetNotFoundError, AuthPreviewOperateScope, - type AssetResource, type EditorId, type EnvironmentId, type ServerConfig, @@ -56,12 +45,10 @@ import { type ServerProviderSkill, type ThreadPullRequestKey, } from "@t3tools/contracts"; -import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; import { isAtomCommandInterrupted, squashAtomCommandFailure, - type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { codexArtifactTemplatePresentationLabel, @@ -72,24 +59,18 @@ import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; -import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; -import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; +import { mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; import React, { - Children, Suspense, - type CSSProperties, type ComponentProps, type ClipboardEvent as ReactClipboardEvent, - type KeyboardEvent as ReactKeyboardEvent, - type MouseEvent as ReactMouseEvent, - isValidElement, use, memo, - useEffect, useLayoutEffect, useRef, useState, @@ -101,7 +82,6 @@ import type { Options as ReactMarkdownOptions, } from "react-markdown"; import ReactMarkdown from "react-markdown"; -import { toHtml } from "hast-util-to-html"; import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; import { defaultUrlTransform } from "react-markdown"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; @@ -109,23 +89,22 @@ import { parseComposerContextHref } from "@t3tools/shared/composerContextReferen import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; +import { mathKind } from "@t3tools/shared/markdownMath"; +import remarkMath from "remark-math"; import { artifactTemplateFromHastProperties, renderCodexInlineDirectivesAsMarkdown, } from "@t3tools/shared/codexMarkdownDirectives"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; +import { MarkdownMath } from "./chat/MarkdownMath"; import { resolveMarkdownMediaPreview, type ExpandedImagePreview, } from "./chat/ExpandedImagePreview"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; -import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery"; -import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; -import { MediaActions, type MediaActionSource } from "./media/MediaActions"; +import { markdownImageGallery } from "./chat/markdownImageGallery"; +import { type MediaActionSource } from "./media/MediaActions"; import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; -import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; -import { FileTagChipContent } from "./chat/FileTagChip"; -import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { revealInFileExplorerLabelForKind, revealInFileExplorerLabelForOs, @@ -134,14 +113,8 @@ import { resolveExternalWebLinkHost, showExternalLinkContextMenu, } from "./chat/externalLinkContextMenu"; -import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; -import { MorphIcon } from "~/components/MorphIcon"; -import { ContextChip } from "./ContextChip"; -import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; -import { ScrollArea } from "./ui/scroll-area"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { recordVisitForThread } from "../browserHistoryStore"; import { @@ -150,36 +123,27 @@ import { usePreferredEditor, } from "../editorPreferences"; import { openInEditorMenuLabel } from "../editorLabels"; -import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; -import { fnv1a32 } from "../lib/diffRendering"; -import { LRUCache } from "../lib/lruCache"; -import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; -import { GitHubIcon } from "./Icons"; -import { createIncrementalHighlightedDocument } from "../lib/incrementalHighlighting"; -import { HighlightedCodeLines } from "./chat/HighlightedCodeLines"; +import { resolveDiffThemeName } from "../lib/diffRendering"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; -import { MermaidDiagram } from "./chat/MermaidDiagram"; -import { useTheme } from "../hooks/useTheme"; -import { getClientSettings, useClientSettings } from "../hooks/useSettings"; -import { - chatMarkdownClipboardPayload, - serializeTableElementToCsv, - serializeTableElementToMarkdown, -} from "../markdown-clipboard"; +import { useRenderedAppearance } from "../hooks/useTheme"; +import { useClientSettings } from "../hooks/useSettings"; +import { chatMarkdownClipboardPayload } from "../markdown-clipboard"; import { resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, - shouldOpenMarkdownFileLinkInBrowserByDefault, - shouldOpenMarkdownFileLinkInEditor, type MarkdownFileLinkMeta, } from "../markdown-links"; +import { + markdownFileCopyPaths, + markdownFileEditorTarget, + resolveMarkdownFileHostPath, +} from "../markdownFileHostPath"; import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; -import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; -import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; +import { resolveRemoteOpenState, useRemoteOpenResolution } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; @@ -189,25 +153,26 @@ import { readEnvironmentScope, readPreparedConnection, useEnvironmentScope, - usePreparedConnection, } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { projectEnvironment } from "../state/projects"; +import { filesystemEnvironment } from "../state/filesystem"; import { claimWorkspaceBasenameLookup, needsWorkspaceBasenameLookup, + pickStrippedWorkspaceMatch, pickWorkspaceBasenameMatch, + stripRepeatedWorkspacePrefix, WORKSPACE_BASENAME_LOOKUP_LIMIT, -} from "../workspaceBasenameLookup"; +} from "@t3tools/client-runtime/workspace-basename-lookup"; import { parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, resolvePullRequestPreviewTarget, useOpenChangeRequestLink, } from "~/lib/openPullRequestLink"; -import { observeResize } from "~/lib/observeResize"; import { useOpenLink } from "../browser/useOpenLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewAvailableFor } from "../browser/previewRuntime"; @@ -220,8 +185,40 @@ import { } from "../browser/openFileInPreview"; import { resolveLinkTarget } from "../browser/browserLinkTarget"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; +import { MarkdownCodeBlock, SuspenseShikiCodeBlock } from "./chat/MarkdownCodeBlock"; +import { MarkdownMermaidCodeBlock } from "./chat/MarkdownMermaidCodeBlock"; +import { MarkdownTable } from "./chat/MarkdownTable"; +import { MarkdownDetails } from "./chat/MarkdownDetails"; +import { + authoredImageSizeStyle, + ChatMarkdownAssetImage, + ChatMarkdownImage, + ChatMarkdownImageFallback, + ChatMarkdownVideo, + MarkdownLinkContext, + markdownImageCopy, +} from "./chat/ChatMarkdownMedia"; +import { MarkdownExternalLinkContent } from "./chat/MarkdownExternalLink"; +import { MarkdownFileLink } from "./chat/MarkdownFileLink"; +import { canUseMarkdownFileShellActions } from "./chat/MarkdownFileLink.logic"; +import { handleMarkdownFragmentClick, rehypeHeadingIds } from "./chat/markdownFragments"; +import { rehypeTextDirection } from "./chat/markdownTextDirection"; +import { hastHasText, hastPlainTextDeep, plainHastText } from "./chat/markdownHast.logic"; +import { + extractCodeBlock, + extractFenceLanguage, + extractFenceTitle, + extractPreCodeMeta, + isClosedCodeFence, + nodeToPlainText, +} from "./chat/MarkdownCodeBlock.logic"; +import { reportMarkdownActionFailure } from "./chat/markdownActionFailure"; + +const isAssetNotFoundError = Schema.is(AssetWorkspaceAssetNotFoundError); interface ChatMarkdownProps { + /** Compact markdown transforms remove blocks before rendering inside a label. */ + inline?: boolean; text: string; cwd: string | undefined; threadRef?: ScopedThreadRef | undefined; @@ -252,41 +249,6 @@ export interface ChatMarkdownContextReference { label: string; } -export function canUseMarkdownFileShellActions( - environmentId: EnvironmentId | null, - remoteOpenMode: RemoteOpenMode, - isRemoteOpenResolved: boolean, -): boolean { - return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec"; -} - -function hasMarkdownFilePrimaryAction(input: { - canOpenInEditor: boolean; - canOpenInBrowser: boolean; - canOpenInPanel: boolean; - canOpenMedia?: boolean; -}): boolean { - return ( - input.canOpenInEditor || - input.canOpenInBrowser || - input.canOpenInPanel || - input.canOpenMedia === true - ); -} - -export function shouldUseMarkdownFileBrowserPrimaryAction(input: { - iconPath: string; - canOpenInEditor: boolean; - canOpenInBrowser: boolean; - canOpenInPanel: boolean; -}): boolean { - return ( - input.canOpenInBrowser && - (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) || - (!input.canOpenInEditor && !input.canOpenInPanel)) - ); -} - const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const EMPTY_REMARK_PLUGINS: NonNullable = []; @@ -336,1953 +298,100 @@ function CodexArtifactTemplateCard(props: { {props.onUse ? ( - ) : null} - - ); -} - -const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; -const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; -const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; -const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; - -interface MarkdownActionFailureContext { - readonly operation: string; - readonly target?: string; - readonly format?: "markdown" | "csv"; - readonly language?: string; - readonly fenceTitle?: string; - readonly copyTarget?: string; -} - -function reportMarkdownActionFailure(context: MarkdownActionFailureContext, cause: unknown): void { - console.error("[chat-markdown] action failed", context, cause); -} - -const highlightedCodeCache = new LRUCache( - MAX_HIGHLIGHT_CACHE_ENTRIES, - MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, -); - -function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { - const firstLineEnd = markdown.indexOf("\n", listItemStart); - const firstLine = markdown.slice( - listItemStart, - firstLineEnd === -1 ? markdown.length : firstLineEnd, - ); - const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/); - if (!match?.[1]) return null; - return listItemStart + firstLine.indexOf(match[1]); -} - -function orderedListGutterStyle( - itemCount: number, - start: unknown, -): { "--list-gutter": string } | undefined { - const parsedStart = Number.parseInt(String(start ?? 1), 10); - const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; - const lastNumber = firstNumber + Math.max(itemCount - 1, 0); - const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); - if (markerWidth <= 1) return undefined; - return { "--list-gutter": `${markerWidth + 2}ch` }; -} - -const GITHUB_ALERT_PRESENTATIONS: Record< - string, - { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string } -> = { - note: { - label: "Note", - Icon: InfoIcon, - borderClassName: "border-blue-500/70", - titleClassName: "text-blue-600 dark:text-blue-400", - }, - tip: { - label: "Tip", - Icon: LightbulbIcon, - borderClassName: "border-emerald-500/70", - titleClassName: "text-emerald-600 dark:text-emerald-400", - }, - important: { - label: "Important", - Icon: MessageSquareWarningIcon, - borderClassName: "border-purple-500/70", - titleClassName: "text-purple-600 dark:text-purple-400", - }, - warning: { - label: "Warning", - Icon: TriangleAlertIcon, - borderClassName: "border-amber-500/70", - titleClassName: "text-amber-600 dark:text-amber-500", - }, - caution: { - label: "Caution", - Icon: OctagonAlertIcon, - borderClassName: "border-red-500/70", - titleClassName: "text-red-600 dark:text-red-400", - }, -}; - -function extractFenceLanguage(className: string | undefined): string { - const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); - const raw = match?.[1] ?? "text"; - // Shiki doesn't bundle a gitignore grammar; ini is a close match (#685) - return raw === "gitignore" ? "ini" : raw; -} - -const FENCE_TITLE_ATTR_REGEX = /(?:^|\s)(?:title|file(?:name)?)=(?:"([^"]+)"|'([^']+)'|(\S+))/i; -const FENCE_FILENAME_TOKEN_REGEX = /^[\w@][\w@./-]*\.[A-Za-z0-9]+$/; - -function extractFenceTitle(meta: string | undefined): string | null { - if (!meta) return null; - const attrMatch = FENCE_TITLE_ATTR_REGEX.exec(meta); - const attrTitle = attrMatch?.[1] ?? attrMatch?.[2] ?? attrMatch?.[3]; - if (attrTitle) return attrTitle; - return meta.split(/\s+/).find((candidate) => FENCE_FILENAME_TOKEN_REGEX.test(candidate)) ?? null; -} - -function extractPreCodeMeta(node: unknown): string | undefined { - const children = ( - node as - | { - children?: Array<{ - type?: string; - tagName?: string; - data?: { meta?: unknown }; - properties?: { dataCodeMeta?: unknown }; - }>; - } - | undefined - )?.children; - const codeNode = children?.find((child) => child?.type === "element" && child.tagName === "code"); - const meta = codeNode?.properties?.dataCodeMeta ?? codeNode?.data?.meta; - return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined; -} - -function isClosedCodeFence(node: ReactMarkdownExtraProps["node"], text: string): boolean { - const start = node?.position?.start.offset; - const end = node?.position?.end.offset; - if (start === undefined || end === undefined) return false; - const source = text.slice(start, end); - const opening = /^(?:`{3,}|~{3,})/.exec(source)?.[0]; - // One class for the blockquote prefix: nested quantifiers here backtrack - // exponentially on code lines that start with many `> ` markers. - const closing = /(?:^|\n)[ \t>]*(`{3,}|~{3,})[ \t\r]*$/.exec(source)?.[1]; - return ( - opening !== undefined && - closing !== undefined && - opening[0] === closing[0] && - closing.length >= opening.length - ); -} - -function nodeToPlainText(node: ReactNode): string { - if (typeof node === "string" || typeof node === "number") { - return String(node); - } - if (Array.isArray(node)) { - return node.map((child) => nodeToPlainText(child)).join(""); - } - if (isValidElement<{ children?: ReactNode }>(node)) { - return nodeToPlainText(node.props.children); - } - return ""; -} - -function extractCodeBlock( - children: ReactNode, -): { className: string | undefined; code: string } | null { - const childNodes = Children.toArray(children); - if (childNodes.length !== 1) { - return null; - } - - const onlyChild = childNodes[0]; - if ( - !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>( - onlyChild, - ) - ) { - return null; - } - // With a custom `code` component the child's type is that component, not - // the "code" tag — the hast node react-markdown attaches still names it. - if (onlyChild.type !== "code" && onlyChild.props.node?.tagName !== "code") { - return null; - } - - return { - className: onlyChild.props.className, - code: nodeToPlainText(onlyChild.props.children), - }; -} - -function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string { - return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`; -} - -function estimateHighlightedSize(html: string, code: string): number { - return Math.max(html.length * 2, code.length * 3); -} - -function readInitialWordWrapSetting(): boolean { - return getClientSettings().wordWrap; -} - -/** - * Whether any cell is clipped in the unwrapped layout. Only then does the wrap - * toggle change anything, so short tables hide it. Measures with the container - * briefly unwrapped and restores it before the browser paints. - */ -function tableHasClippedCells(container: HTMLElement, table: HTMLTableElement): boolean { - const viewport = table.closest('[data-slot="scroll-area-viewport"]'); - const scrollLeft = viewport?.scrollLeft ?? 0; - const previous = container.dataset.expanded; - container.dataset.expanded = "false"; - try { - for (const row of table.rows) { - for (const cell of row.cells) { - if (cell.scrollWidth > cell.clientWidth) return true; - } - } - return false; - } finally { - if (previous === undefined) delete container.dataset.expanded; - else container.dataset.expanded = previous; - // The unwrapped layout can be narrower and clamp a sideways scroll. - if (viewport && scrollLeft > 0) viewport.scrollLeft = scrollLeft; - } -} - -function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { - const containerRef = useRef(null); - const tableRef = useRef(null); - const [expanded, setExpanded] = useState(readInitialWordWrapSetting); - const [hasClippedCells, setHasClippedCells] = useState(false); - const [copied, setCopied] = useState(false); - const copiedTimerRef = useRef | null>(null); - const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; - const copyLabel = copied ? "Copied" : "Copy table"; - - const measureClippedCells = () => { - const container = containerRef.current; - const table = tableRef.current; - if (!container || !table) return; - setHasClippedCells(tableHasClippedCells(container, table)); - }; - - // A streamed cell can grow until it clips while the table keeps its size, - // so remeasure after every render, not only on resize. - useLayoutEffect(measureClippedCells); - - // Column width changes can clip a cell without rendering this table. - useLayoutEffect(() => { - const table = tableRef.current; - if (!table) return; - return observeResize(table, measureClippedCells); - }, [measureClippedCells]); - - function toggleExpanded() { - const table = tableRef.current; - if (!table) return; - - if (!expanded) { - const rows = [...table.rows]; - const columnWidths = rows.reduce((widths, row) => { - [...row.cells].forEach((cell, columnIndex) => { - widths[columnIndex] = Math.max( - widths[columnIndex] ?? 0, - cell.getBoundingClientRect().width, - ); - }); - return widths; - }, []); - - [...(table.tHead?.rows[0]?.cells ?? [])].forEach((cell, columnIndex) => { - cell.style.minWidth = `${columnWidths[columnIndex] ?? cell.getBoundingClientRect().width}px`; - }); - } - - setExpanded((value) => !value); - } - - const handleCopy = (format: "markdown" | "csv") => { - const table = containerRef.current?.querySelector("table"); - if (!table || typeof navigator === "undefined" || navigator.clipboard == null) { - return; - } - const text = - format === "markdown" - ? serializeTableElementToMarkdown(table) - : serializeTableElementToCsv(table); - void navigator.clipboard - .writeText(text) - .then(() => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - } - setCopied(true); - copiedTimerRef.current = setTimeout(() => { - setCopied(false); - copiedTimerRef.current = null; - }, 1200); - }) - .catch((cause) => { - reportMarkdownActionFailure({ operation: "copy-table", format }, cause); - }); - }; - - useEffect( - () => () => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - copiedTimerRef.current = null; - } - }, - [], - ); - - return ( -
- - - {children} -
-
-
- {hasClippedCells ? ( - - - } - > - - - {expandLabel} - - ) : ( - - )} - - - - } - /> - } - > - - - {copyLabel} - - - handleCopy("markdown")}>Copy as Markdown - handleCopy("csv")}>Copy as CSV - - -
-
- ); -} - -function MarkdownDetails({ - children, - open = false, -}: Pick, "children" | "open">) { - const [isOpen, setIsOpen] = useState(open); - const searching = use(MarkdownFindContext); - const expanded = isOpen; - const revealForFind = () => setIsOpen(true); - // Base UI only listens for `beforematch` on a panel mounted at its first - // render; a closed panel mounts later, when find starts, so listen here. - const revealRef = findRevealRef(revealForFind); - const childNodes = Children.toArray(children); - const summaryIndex = childNodes.findIndex( - (child) => isValidElement(child) && child.type === "summary", - ); - const summaryNode = summaryIndex >= 0 ? childNodes[summaryIndex] : null; - const summary = - isValidElement<{ children?: ReactNode }>(summaryNode) && summaryNode.props.children - ? summaryNode.props.children - : "Details"; - const content = childNodes.filter((_, index) => index !== summaryIndex); - - return ( -
- - - - {summary} - - -
- {content} -
-
-
-
- ); -} - -const THREAD_FIND_IGNORE_PROPS = { - "data-thread-find-ignore": "", -} as React.HTMLAttributes; - -export function MarkdownCodeBlockFrame({ - as: Wrapper = "div", - language, - fenceTitle, - theme, - wrapped = true, - title, - actions, - headerProps, - children, -}: { - as?: React.ElementType; - language: string; - fenceTitle: string | null; - theme: "light" | "dark"; - wrapped?: boolean; - title?: React.ReactNode; - actions?: React.ReactNode; - headerProps?: React.HTMLAttributes; - children: React.ReactNode; -}) { - return ( - -
- {title ?? ( - - - - )} - {actions} -
- {children} -
- ); -} - -function MarkdownCodeBlockTitleContent({ - fenceTitle, - language, - theme, -}: { - fenceTitle: string | null; - language: string; - theme: "light" | "dark"; -}) { - if (fenceTitle) { - return ( - <> - - {fenceTitle} - - ); - } - - const fileName = syntheticFileNameForLanguageId(language); - if (!hasSpecificPierreIconForFileName(fileName)) { - return {language}; - } - return ( - - - } - > - - - {language} - - ); -} - -function MarkdownCodeBlock({ - code, - language, - fenceTitle, - theme, - onRunShellCommand, - isStreaming, - viewToggle, - canWrap = true, - diagram = false, - children, -}: { - code: string; - language: string; - fenceTitle: string | null; - theme: "light" | "dark"; - onRunShellCommand?: ((command: string) => void) | undefined; - isStreaming: boolean; - viewToggle?: ReactNode; - canWrap?: boolean; - diagram?: boolean; - children: ReactNode; -}) { - const [copied, setCopied] = useState(false); - const [wrapped, setWrapped] = useState(readInitialWordWrapSetting); - const copiedTimerRef = useRef | null>(null); - const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; - const copyLabel = copied ? "Copied" : "Copy code"; - const command = code.trim(); - const canRun = - onRunShellCommand !== undefined && - !isStreaming && - /^(?:sh|bash|zsh|fish|shell|powershell|pwsh)$/.test(language) && - code.endsWith("\n") && - command.length > 0 && - !command.endsWith("\\") && - // Control and invisible format characters (bidi overrides, zero-width) can - // make the rendered command differ from what the terminal would receive. - !/[\p{Cc}\p{Cf}]/u.test(code.slice(0, -1)); - - const handleCopy = () => { - if (typeof navigator === "undefined" || navigator.clipboard == null) { - return; - } - void navigator.clipboard - .writeText(code) - .then(() => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - } - setCopied(true); - copiedTimerRef.current = setTimeout(() => { - setCopied(false); - copiedTimerRef.current = null; - }, 1200); - }) - .catch((cause) => { - reportMarkdownActionFailure( - { - operation: "copy-code-block", - language, - ...(fenceTitle ? { fenceTitle } : {}), - }, - cause, - ); - }); - }; - - useEffect( - () => () => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - copiedTimerRef.current = null; - } - }, - [], - ); - - const copyButton = ( - - - } - > - - - {copyLabel} - - ); - - if (diagram) { - return ( -
-
- {viewToggle} - {copyButton} -
- {children} -
- ); - } - - return ( - - {canWrap ? ( - - setWrapped((value) => !value)} - aria-label={wrapLabel} - /> - } - > - - - {wrapLabel} - - ) : null} - {canRun ? ( - - onRunShellCommand(command)} - aria-label="Run in terminal" - /> - } - > - - - Run in terminal - - ) : null} - {viewToggle} - {copyButton} - - } - > - {children} - - ); -} - -function MarkdownMermaidCodeBlock({ - code, - fenceTitle, - theme, - isStreaming, - onExpand, - children, -}: { - code: string; - fenceTitle: string | null; - theme: "light" | "dark"; - isStreaming: boolean; - onExpand: (imageUrl: string) => void; - children: ReactNode; -}) { - const [showCode, setShowCode] = useState(false); - const searching = use(MarkdownFindContext); - const revealSource = () => setShowCode(true); - const sourceRevealRef = findRevealRef(revealSource); - const showDiagram = !showCode && !isStreaming && code.trim().length > 0; - const toggleLabel = showCode ? "Show diagram" : "Show code"; - return ( - - setShowCode((value) => !value)} - aria-label={toggleLabel} - /> - } - > - {showCode ? : } - - {toggleLabel} - - ) - } - > - {showDiagram ? ( - - - Rendering diagram - - } - > - - - {searching ? ( - - ) : null} - - ) : ( - children - )} - - ); -} - -interface SuspenseShikiCodeBlockProps { - className: string | undefined; - code: string; - themeName: DiffThemeName; - isStreaming: boolean; -} - -function SuspenseShikiCodeBlock({ - className, - code, - themeName, - isStreaming, -}: SuspenseShikiCodeBlockProps) { - const [hasStreamed, setHasStreamed] = useState(isStreaming); - if (isStreaming && !hasStreamed) setHasStreamed(true); - const language = extractFenceLanguage(className); - const cacheKey = createHighlightCacheKey(code, language, themeName); - // Once lines are mounted individually, keep that renderer when streaming - // finishes so switching to cached HTML cannot clear an existing selection. - const cachedHighlightedHtml = - !isStreaming && !hasStreamed ? highlightedCodeCache.get(cacheKey) : null; - - if (cachedHighlightedHtml != null) { - return ( -
- ); - } - - return ( - - ); -} - -interface UncachedShikiCodeBlockProps { - code: string; - language: string; - themeName: DiffThemeName; - cacheKey: string; - isStreaming: boolean; - preserveLines: boolean; -} - -function UncachedShikiCodeBlock({ - code, - language, - themeName, - cacheKey, - isStreaming, - preserveLines, -}: UncachedShikiCodeBlockProps) { - const highlighter = use(getSyntaxHighlighterPromise(language)); - const incrementalHighlight = preserveLines - ? createIncrementalHighlightedDocument(highlighter, language, themeName) - : null; - const highlighted = (() => { - try { - if (incrementalHighlight) return incrementalHighlight(code); - return preserveLines - ? highlighter.codeToHast(code, { lang: language, theme: themeName }) - : highlighter.codeToHtml(code, { lang: language, theme: themeName }); - } catch (error) { - console.warn( - `Code highlighting failed for language "${language}", falling back to plain text.`, - error instanceof Error ? error.message : error, - ); - return preserveLines - ? highlighter.codeToHast(code, { lang: "text", theme: themeName }) - : highlighter.codeToHtml(code, { lang: "text", theme: themeName }); - } - })(); - - useEffect(() => { - if (!isStreaming) { - const highlightedHtml = typeof highlighted === "string" ? highlighted : toHtml(highlighted); - highlightedCodeCache.set( - cacheKey, - highlightedHtml, - estimateHighlightedSize(highlightedHtml, code), - ); - } - }, [cacheKey, code, highlighted, isStreaming]); - - return typeof highlighted === "string" ? ( -
- ) : ( -
- -
- ); -} - -interface MarkdownFileLinkProps { - environmentId: EnvironmentId | null; - href: string; - targetPath: string; - iconPath: string; - displayPath: string; - panelPath: string | null; - line?: number | undefined; - label: string; - copyMarkdown: string; - theme: "light" | "dark"; - threadRef?: ScopedThreadRef | undefined; - onOpen?: ((targetPath: string) => Promise>) | undefined; - onOpenInPanel: (panelPath: string, line: number | undefined) => void; - openInEditorMenuLabel: string; - onOpenInBrowser?: (() => Promise>) | undefined; - onOpenMedia?: (() => void) | undefined; - onReveal?: (() => Promise>) | undefined; - revealLabel?: string | undefined; -} - -const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link"; - -function normalizeMarkdownLinkHrefKey(href: string): string { - const normalizedHref = normalizeMarkdownLinkDestination(href); - const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; - return WINDOWS_DRIVE_PATH_REGEX.test(rewrittenHref) - ? rewrittenHref.replaceAll("\\", "/") - : rewrittenHref; -} - -const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; - -const failedFaviconHosts = new Set(); - -function isGitHubHost(host: string): boolean { - const hostname = host.toLowerCase(); - return hostname === "github.com" || hostname.endsWith(".github.com"); -} - -const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { - const [failedHost, setFailedHost] = useState(null); - const brandIcon = isGitHubHost(host); - const faviconUrl = brandIcon ? null : faviconUrlForOrigin(`https://${host}`); - return ( - - {brandIcon ? ( - - ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( - - ) : ( - { - failedFaviconHosts.add(host); - setFailedHost(host); - }} - /> - )} - - ); -}); - -const CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME = "max-w-[min(100%,30rem)]"; -const CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME = cn( - "max-h-[30rem]", - CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, -); -const CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME = "inline-block!"; -const CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME = "rounded-lg border border-border/40"; -const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( - "h-auto w-auto object-contain", - CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, -); - -function markdownImageCopy(alt: string, src: string, title: string | undefined): string { - const escapedAlt = alt.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]"); - const titleSuffix = - title === undefined ? "" : ` "${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; - return `![${escapedAlt}](${src}${titleSuffix})`; -} - -/** - * `maxHeightRem` folds a height cap into the width bound: `max-height` alone - * would not feed back through `aspect-ratio` once `width` is definite, so a - * tall image would keep a box wider than the picture it draws. - */ -function authoredImageSizeStyle( - width: string | number | undefined, - height: string | number | undefined, - maxHeightRem = 30, -): CSSProperties | undefined { - const parsedWidth = Number(width); - const parsedHeight = Number(height); - const hasWidth = Number.isFinite(parsedWidth) && parsedWidth > 0; - const hasHeight = Number.isFinite(parsedHeight) && parsedHeight > 0; - if (hasWidth && hasHeight) { - return { - width: parsedWidth, - height: "auto", - aspectRatio: `${parsedWidth} / ${parsedHeight}`, - maxWidth: `min(100%, 30rem, ${(maxHeightRem * parsedWidth) / parsedHeight}rem)`, - }; - } - if (hasWidth) return { maxWidth: `min(100%, 30rem, ${parsedWidth}px)` }; - if (hasHeight) return { maxHeight: `min(30rem, ${parsedHeight}px)` }; - return undefined; -} - -const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( - CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, - CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, -); -const MarkdownLinkContext = React.createContext(false); - -function expandableMarkdownImageProps( - onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, - alt: string, -) { - if (!onImageExpand) return {}; - const previewName = alt.trim() || "image"; - const expand = (event: ReactMouseEvent | ReactKeyboardEvent) => { - if (event.currentTarget.closest("a")) return; - event.preventDefault(); - event.stopPropagation(); - const item = markdownImageItems.get(event.currentTarget); - if (item) onImageExpand(markdownImageGallery(event.currentTarget, item)); - }; - return { - role: "button" as const, - tabIndex: 0, - "aria-label": `Preview ${previewName}`, - onClick: expand, - onKeyDown: (event: ReactKeyboardEvent) => { - if (event.key === "Enter" || event.key === " ") expand(event); - }, - }; -} - -function ChatMarkdownMediaUnavailableLabel(props: { - readonly alt: string; - readonly kind?: "image" | "video" | undefined; - readonly detail?: string | undefined; -}) { - const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; - return ( - - - {[label, props.alt, props.detail].filter(Boolean).join(" · ")} - - ); -} - -function ChatMarkdownImageFallback(props: { - readonly alt: string; - readonly copyMarkdown?: string | undefined; - readonly kind?: "image" | "video"; - readonly actionsSource?: MediaActionSource | undefined; - readonly detail?: string | undefined; -}) { - const content = ( - - - - ); - return props.actionsSource ? ( - {content} - ) : ( - content - ); -} - -const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn( - "aspect-video w-full overflow-hidden bg-muted/60", - CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, - CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, -); - -/** - * Callers key this on the file's identity, not its URL: a re-signed URL for - * the same file keeps the decoded image on screen while the new bytes arrive, - * and a different file starts from the slot again. - */ -function ChatMarkdownImage(props: { - readonly src: string | null; - readonly sourceFailed?: boolean | undefined; - readonly unavailableDetail?: string | undefined; - readonly alt: string; - readonly copyMarkdown: string | undefined; - readonly standalone: boolean; - readonly className?: string | undefined; - readonly style?: CSSProperties | undefined; - readonly imageProps?: - | Omit, "src" | "alt" | "className" | "style"> - | undefined; - readonly actionsSource: MediaActionSource; - readonly originalUrl?: string | undefined; - readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; -}) { - const [loadedSrc, setLoadedSrc] = useState(null); - const [failedSrc, setFailedSrc] = useState(null); - const src = props.src ?? loadedSrc; - const failed = props.sourceFailed === true || (src !== null && failedSrc === src); - const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null); - // Cached images are complete before `onLoad` can fire. - const markLoadedIfComplete = (image: HTMLImageElement | null) => { - if (!image) return; - if (image.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src); - markdownImageItems.set(image, { - src, - name: props.alt.trim() || "image", - actionsSource: props.actionsSource, - ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), - }); - }; - const imageEvents = (loadingSrc: string) => ({ - onLoad: () => { - setLoadedSrc(loadingSrc); - setFailedSrc(null); - }, - onError: () => { - setFailedSrc(loadingSrc); - setLoadedSrc(null); - }, - }); - - if (settled) { - return ( - - {props.alt} - - ); - } - if (!props.standalone) { - return failed ? ( - - ) : ( - - ); - } - return ( - - - {failed ? ( - - - - ) : src !== null ? ( - {props.alt} - ) : null} - - - ); -} - -function ChatMarkdownVideo(props: { - readonly src: string | null; - readonly alt: string; - readonly copyMarkdown: string | undefined; - readonly originalUrl?: string | undefined; - readonly sourceFailed?: boolean | undefined; - readonly style?: CSSProperties | undefined; - readonly mediaIdentity?: string | undefined; - readonly actionsSource?: MediaActionSource | undefined; - readonly onRetry?: (() => Promise) | undefined; -}) { - return ( - - ); -} - -export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { - readonly environmentId: EnvironmentId; - readonly resource: Extract< - AssetResource, - { - readonly _tag: - | "attachment" - | "workspace-file" - | "media-file" - | "github-media" - | "tool-output-image"; - } - >; - readonly kind?: "image" | "video"; - readonly alt: string; - readonly copyMarkdown?: string; - readonly srcFragment?: string; - readonly standalone?: boolean | undefined; - readonly maxHeightRem?: number | undefined; - readonly style?: CSSProperties | undefined; - readonly className?: string | undefined; - readonly imageProps?: - | Omit, "src" | "alt" | "className" | "style"> - | undefined; - readonly originalUrl?: string | undefined; - readonly framed?: boolean | undefined; - readonly fallbackSrc?: string | undefined; - readonly workspaceRoot?: string | undefined; - readonly workspaceMutationId?: string | null | undefined; - readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; -}) { - const assetUrl = useAssetUrlState(props.environmentId, props.resource); - const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); - const connectionReady = usePreparedConnection(props.environmentId)._tag === "Some"; - const resource = props.resource; - const refreshToken = JSON.stringify([props.environmentId, resource, props.workspaceMutationId]); - const handledRefreshToken = useRef(undefined); - const assetLoading = assetUrl._tag === "Loading" || assetUrl.waiting === true; - useEffect(() => { - if (!connectionReady) { - handledRefreshToken.current = null; - return; - } - if ( - props.workspaceMutationId === undefined || - props.kind === "video" || - (resource._tag !== "media-file" && resource._tag !== "workspace-file") || - handledRefreshToken.current === refreshToken - ) { - return; - } - if (assetLoading) { - // Only the initial mount shares a pending request; edits and reconnects need a new one. - if (handledRefreshToken.current === undefined) { - handledRefreshToken.current = refreshToken; - } - return; - } - handledRefreshToken.current = refreshToken; - void refreshAssetUrl().catch(() => undefined); - }, [ - assetLoading, - connectionReady, - props.kind, - props.workspaceMutationId, - refreshAssetUrl, - refreshToken, - resource._tag, - ]); - const path = - resource._tag === "media-file" - ? resource.path - : resource._tag === "workspace-file" && props.workspaceRoot - ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` - : undefined; - const reference = path - ? mediaFileReference(path, props.workspaceRoot) - : props.originalUrl - ? mediaUrlReference(props.originalUrl) - : undefined; - const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; - const fallbackSrc = assetUrl._tag === "Failure" ? props.fallbackSrc : undefined; - const src = - assetUrl._tag === "Success" - ? assetUrl.url + (props.srcFragment ?? "") - : fallbackSrc === undefined - ? null - : fallbackSrc + (props.srcFragment ?? ""); - const knownSize = assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined; - const maxHeightRem = props.maxHeightRem ?? 30; - const style = - props.style ?? - (knownSize - ? authoredImageSizeStyle(knownSize.width, knownSize.height, maxHeightRem) - : maxHeightRem !== 30 - ? { maxHeight: `${maxHeightRem}rem` } - : undefined); - const actionsSource: MediaActionSource = { - kind: props.kind ?? "image", - name: props.alt || (props.kind ?? "image"), - src, - ...(fallbackSrc === undefined - ? { asset: { environmentId: props.environmentId, resource } } - : {}), - ...(reference ? { reference } : {}), - ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file") - ? { - onOpenFile: () => - useRightPanelStore - .getState() - .openFile( - { environmentId: props.environmentId, threadId: resource.threadId }, - relativePath, - ), - } - : {}), - }; - - if (props.kind === "video") { - return ( - - ); - } - - return ( - - ); -}); - -function leadingExternalLinkTextLength(text: string): number { - const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0]; - if (protocol) return protocol.length; - return Math.min(text.length, 1); -} - -function breakableExternalLinkText(text: string): ReactNode[] { - return Array.from(text, (character, index) => ( - - {character} - - - )); -} - -function plainHastText(node: unknown): string | null { - if (!node || typeof node !== "object" || !("children" in node) || !Array.isArray(node.children)) { - return null; - } - const parts = node.children.map((child) => { - if ( - child && - typeof child === "object" && - "type" in child && - child.type === "text" && - "value" in child && - typeof child.value === "string" - ) { - return child.value; - } - return null; - }); - return parts.every((part) => part !== null) ? parts.join("") : null; -} - -function hastPlainTextDeep(node: unknown): string { - if (!node || typeof node !== "object") return ""; - if ("type" in node && node.type === "text" && "value" in node && typeof node.value === "string") { - return node.value; - } - if (!("children" in node) || !Array.isArray(node.children)) return ""; - return node.children.map(hastPlainTextDeep).join(""); -} - -function hastHasText(node: unknown): boolean { - if (!node || typeof node !== "object") return false; - if ( - "type" in node && - node.type === "text" && - "value" in node && - typeof node.value === "string" && - node.value.trim().length > 0 - ) { - return true; - } - return "children" in node && Array.isArray(node.children) && node.children.some(hastHasText); -} - -const SANITIZED_FRAGMENT_PREFIX = "user-content-"; - -function decodeMarkdownFragmentId(href: string): string { - const encodedId = href.slice(1); - try { - return decodeURIComponent(encodedId); - } catch { - return encodedId; - } -} - -function normalizeSanitizedFragmentId(id: string): string { - let normalizedId = id; - while (normalizedId.startsWith(SANITIZED_FRAGMENT_PREFIX)) { - normalizedId = normalizedId.slice(SANITIZED_FRAGMENT_PREFIX.length); - } - return normalizedId; + data-thread-find-ignore + type="button" + size="sm" + variant="outline" + className="shrink-0" + onClick={() => props.onUse?.(props.template)} + > + Use template + + ) : null} +
+ ); } -function findMarkdownFragmentTarget(anchor: HTMLAnchorElement, href: string): HTMLElement | null { - const decodedId = decodeMarkdownFragmentId(href); - const normalizedId = normalizeSanitizedFragmentId(decodedId); - const matchesFragment = (element: HTMLElement) => - element.id === decodedId || normalizeSanitizedFragmentId(element.id) === normalizedId; - const markdownRoot = anchor.closest(".chat-markdown"); - if (markdownRoot) { - const localTargets = Array.from(markdownRoot.querySelectorAll("[id]")); - const localTarget = localTargets.find(matchesFragment); - if (localTarget) return localTarget; - } +const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; - return ( - document.getElementById(decodedId) ?? - Array.from(document.querySelectorAll("[id]")).find(matchesFragment) ?? - null +function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { + const firstLineEnd = markdown.indexOf("\n", listItemStart); + const firstLine = markdown.slice( + listItemStart, + firstLineEnd === -1 ? markdown.length : firstLineEnd, ); + const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/); + if (!match?.[1]) return null; + return listItemStart + firstLine.indexOf(match[1]); } -function handleMarkdownFragmentClick(event: ReactMouseEvent, href: string) { - if ( - event.defaultPrevented || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) { - return; - } - - // Never let the browser follow the fragment or write it to the URL: desktop keeps - // its route in the hash, so replacing the hash navigates away from the thread. - event.preventDefault(); - findMarkdownFragmentTarget(event.currentTarget, href)?.scrollIntoView({ block: "start" }); +function orderedListGutterStyle( + itemCount: number, + start: unknown, +): { "--list-gutter": string } | undefined { + const parsedStart = Number.parseInt(String(start ?? 1), 10); + const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; + const lastNumber = firstNumber + Math.max(itemCount - 1, 0); + const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); + if (markerWidth <= 1) return undefined; + return { "--list-gutter": `${markerWidth + 2}ch` }; } -type HeadingHastNode = { - type?: string; - tagName?: string; - properties?: Record; - children?: HeadingHastNode[]; +const GITHUB_ALERT_PRESENTATIONS: Record< + string, + { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string } +> = { + note: { + label: "Note", + Icon: InfoIcon, + borderClassName: "border-blue-500/70", + titleClassName: "text-blue-600 dark:text-blue-400", + }, + tip: { + label: "Tip", + Icon: LightbulbIcon, + borderClassName: "border-emerald-500/70", + titleClassName: "text-emerald-600 dark:text-emerald-400", + }, + important: { + label: "Important", + Icon: MessageSquareWarningIcon, + borderClassName: "border-purple-500/70", + titleClassName: "text-purple-600 dark:text-purple-400", + }, + warning: { + label: "Warning", + Icon: TriangleAlertIcon, + borderClassName: "border-amber-500/70", + titleClassName: "text-amber-600 dark:text-amber-500", + }, + caution: { + label: "Caution", + Icon: OctagonAlertIcon, + borderClassName: "border-red-500/70", + titleClassName: "text-red-600 dark:text-red-400", + }, }; -function githubHeadingSlug(text: string): string { - return text - .trim() - .toLowerCase() - .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "") - .replace(/ /g, "-"); -} - -/** - * Like the sanitizer's ids, they carry the `user-content-` prefix so they cannot clobber app - * element ids; fragment lookup strips it. - */ -function rehypeHeadingIds() { - return (tree: HeadingHastNode) => { - const taken = new Set(); - const collect = (node: HeadingHastNode) => { - const id = node.properties?.id; - if (typeof id === "string") taken.add(id); - node.children?.forEach(collect); - }; - collect(tree); - const nextSuffix = new Map(); - const visit = (node: HeadingHastNode) => { - if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) { - const slug = githubHeadingSlug(hastPlainTextDeep(node)); - if (node.properties?.id === undefined && slug) { - let count = nextSuffix.get(slug) ?? 0; - let id = `${SANITIZED_FRAGMENT_PREFIX}${slug}`; - while (taken.has(id)) { - count += 1; - id = `${SANITIZED_FRAGMENT_PREFIX}${slug}-${count}`; - } - nextSuffix.set(slug, count); - taken.add(id); - node.properties = { ...node.properties, id }; - } - return; - } - node.children?.forEach(visit); - }; - visit(tree); - }; +function normalizeMarkdownLinkHrefKey(href: string): string { + const normalizedHref = normalizeMarkdownLinkDestination(href); + const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; + return WINDOWS_DRIVE_PATH_REGEX.test(rewrittenHref) + ? rewrittenHref.replaceAll("\\", "/") + : rewrittenHref; } // Heading ids are added after sanitizing, which would prefix them a second time. const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ ...CHAT_MARKDOWN_REHYPE_PLUGINS, rehypeHeadingIds, + rehypeTextDirection, ] satisfies NonNullable; -const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable< - ReactMarkdownOptions["rehypePlugins"] ->; - -function MarkdownExternalLinkContent({ - host, - plainText, - children, -}: { - host: string; - plainText: string | null; - children: ReactNode; -}) { - if (plainText) { - const leadingLength = leadingExternalLinkTextLength(plainText); - return ( - <> - - - {plainText.slice(0, leadingLength)} - - {breakableExternalLinkText(plainText.slice(leadingLength))} - - ); - } - - const childNodes = Children.toArray(children); - const firstChild = childNodes[0]; - - if (typeof firstChild === "string" && firstChild.length > 0) { - const leadingLength = leadingExternalLinkTextLength(firstChild); - return ( - <> - - - {firstChild.slice(0, leadingLength)} - - {breakableExternalLinkText(firstChild.slice(leadingLength))} - {childNodes.slice(1)} - - ); - } - - return ( - <> - - - {firstChild} - - {childNodes.slice(1)} - - ); -} - -const MarkdownFileLink = memo(function MarkdownFileLink({ - environmentId, - href, - targetPath, - iconPath, - displayPath, - panelPath, - line, - label, - copyMarkdown, - theme, - threadRef, - onOpen, - onOpenInPanel, - openInEditorMenuLabel, - onOpenInBrowser, - onOpenMedia, - onReveal, - revealLabel, -}: MarkdownFileLinkProps) { - const handleOpenInEditor = () => { - if (!onOpen) { - return; - } - void (async () => { - try { - const result = await onOpen(targetPath); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - reportMarkdownActionFailure( - { operation: "open-file-in-editor", target: targetPath }, - result.cause, - ); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } catch (cause) { - reportMarkdownActionFailure( - { operation: "open-file-in-editor", target: targetPath }, - cause, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file", - description: cause instanceof Error ? cause.message : "An error occurred.", - }), - ); - } - })(); - }; - - const handleOpenInFilePreview = () => { - if (threadRef && panelPath) { - onOpenInPanel(panelPath, line); - return; - } - if (onOpenMedia) { - onOpenMedia(); - return; - } - handleOpenInEditor(); - }; - - const handleOpenInBrowser = () => { - if (!onOpenInBrowser) { - return; - } - void (async () => { - try { - const result = await onOpenInBrowser(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - reportMarkdownActionFailure( - { operation: "open-file-in-browser", target: targetPath }, - result.cause, - ); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file in browser", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } catch (cause) { - reportMarkdownActionFailure( - { operation: "open-file-in-browser", target: targetPath }, - cause, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file in browser", - description: cause instanceof Error ? cause.message : "An error occurred.", - }), - ); - } - })(); - }; - - const handleRevealInFileManager = () => { - if (!onReveal) { - return; - } - void (async () => { - try { - const result = await onReveal(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - reportMarkdownActionFailure( - { operation: "reveal-file-in-file-manager", target: targetPath }, - result.cause, - ); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to reveal file", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } catch (cause) { - reportMarkdownActionFailure( - { operation: "reveal-file-in-file-manager", target: targetPath }, - cause, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to reveal file", - description: cause instanceof Error ? cause.message : "An error occurred.", - }), - ); - } - })(); - }; - - const handleCopy = (value: string, title: string) => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to copy ${title.toLowerCase()}`, - description: "Clipboard API unavailable.", - }), - ); - return; - } - - void navigator.clipboard.writeText(value).then( - () => { - toastManager.add({ - type: "success", - title: `${title} copied`, - description: value, - }); - }, - (error) => { - reportMarkdownActionFailure( - { operation: "copy-file-path", target: targetPath, copyTarget: title }, - error, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to copy ${title.toLowerCase()}`, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - ); - }; - - const showFileContextMenu = async (position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - - try { - const clicked = await api.contextMenu.show( - [ - ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), - ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), - ...(onOpenInBrowser - ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) - : []), - ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), - { id: "copy-relative", label: "Copy relative path" }, - { id: "copy-full", label: "Copy full path" }, - ] as const, - position, - ); - - if (clicked === "preview-media") { - onOpenMedia?.(); - return; - } - if (clicked === "open") { - handleOpenInEditor(); - return; - } - if (clicked === "open-in-browser") { - handleOpenInBrowser(); - return; - } - if (clicked === "reveal") { - handleRevealInFileManager(); - return; - } - if (clicked === "copy-relative") { - handleCopy(displayPath, "Relative path"); - return; - } - if (clicked === "copy-full") { - handleCopy(targetPath, "Full path"); - } - } catch (cause) { - reportMarkdownActionFailure( - { operation: "show-file-context-menu", target: targetPath }, - cause, - ); - } - }; - - const handleContextMenu = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - const position = - event.clientX === 0 && event.clientY === 0 - ? (() => { - const bounds = event.currentTarget.getBoundingClientRect(); - return { x: bounds.left, y: bounds.bottom }; - })() - : { x: event.clientX, y: event.clientY }; - void showFileContextMenu(position); - }; - - const canOpenInEditor = onOpen !== undefined; - const canOpenInBrowser = onOpenInBrowser !== undefined; - const canOpenInPanel = threadRef !== undefined && Boolean(panelPath); - const hasPrimaryAction = hasMarkdownFilePrimaryAction({ - canOpenInEditor, - canOpenInBrowser, - canOpenInPanel, - canOpenMedia: onOpenMedia !== undefined, - }); - const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({ - iconPath, - canOpenInEditor, - canOpenInBrowser, - canOpenInPanel, - }); - - return ( - - } - className={MARKDOWN_FILE_LINK_CLASS_NAME} - data-markdown-copy={copyMarkdown} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) { - handleOpenInEditor(); - return; - } - if (useBrowserPrimaryAction) { - handleOpenInBrowser(); - return; - } - handleOpenInFilePreview(); - }} - onContextMenu={handleContextMenu} - > - - - ) : ( - } - aria-label={`File options for ${label}`} - aria-haspopup="menu" - className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")} - data-markdown-copy={copyMarkdown} - onClick={handleContextMenu} - onContextMenu={handleContextMenu} - > - - - ) - } - /> - -
- -
-
-
- ); -}, areMarkdownFileLinkPropsEqual); - -function areMarkdownFileLinkPropsEqual( - previous: Readonly, - next: Readonly, -): boolean { - return ( - previous.href === next.href && - previous.targetPath === next.targetPath && - previous.iconPath === next.iconPath && - previous.environmentId === next.environmentId && - previous.displayPath === next.displayPath && - previous.panelPath === next.panelPath && - previous.line === next.line && - previous.label === next.label && - previous.copyMarkdown === next.copyMarkdown && - previous.theme === next.theme && - previous.threadRef === next.threadRef && - previous.onOpen === next.onOpen && - previous.onOpenInPanel === next.onOpenInPanel && - previous.openInEditorMenuLabel === next.openInEditorMenuLabel && - previous.onOpenInBrowser === next.onOpenInBrowser && - previous.onOpenMedia === next.onOpenMedia && - previous.onReveal === next.onReveal && - previous.revealLabel === next.revealLabel - ); -} +const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [ + rehypeHeadingIds, + rehypeTextDirection, +] satisfies NonNullable; const NO_MARKDOWN_EDITORS: ReadonlyArray = []; @@ -2327,6 +436,7 @@ const markdownServerConfigFieldsAtom = Atom.family((environmentId: EnvironmentId function useChatMarkdownState({ text, + inline = false, cwd, threadRef, pullRequestPanelRef, @@ -2343,8 +453,8 @@ function useChatMarkdownState({ headingLevelOffset = 0, githubMedia = false, }: ChatMarkdownProps) { - const { resolvedTheme } = useTheme(); - const markdownRef = useRef(null); + const resolvedTheme = useRenderedAppearance(); + const markdownRef = useRef(null); const mediaRequestId = useRef(0); // A preview opened from another thread or workspace never carries over. const mediaScopeKey = [ @@ -2386,6 +496,10 @@ function useChatMarkdownState({ const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { reportFailure: false, }); + const getFilesystemMetadata = useAtomQueryRunner(filesystemEnvironment.getMetadata, { + reportFailure: false, + refresh: true, + }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); @@ -2429,14 +543,34 @@ function useChatMarkdownState({ }, (error: unknown) => { if (mediaRequestId.current !== requestId) return; + let description = + error instanceof Error + ? error.message + : "The file could not be loaded. It may have been moved or deleted."; + if ( + isAssetNotFoundError(error) && + error.resource._tag === "media-file" && + environmentId !== null + ) { + // The connection may resolve while the media lookup is pending. + const target = readPreparedConnection(environmentId)?.target ?? null; + if ( + target !== null && + resolveRemoteOpenState({ + target, + sshTarget: null, + remoteOpenTargets: undefined, + isDesktopRenderer: window.desktopBridge !== undefined, + }).mode !== "local-exec" + ) { + description = `The file was not found on ${target.label} at ${error.checkedPath ?? error.resource.path}.`; + } + } toastManager.add( stackedThreadToast({ type: "error", title: "Media unavailable", - description: - error instanceof Error - ? error.message - : "The file could not be loaded. It may have been moved or deleted.", + description, }), ); }, @@ -2514,7 +648,7 @@ function useChatMarkdownState({ if (isWindowsDrivePathHref(href)) return href; return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }; - const handleCopy = (event: ReactClipboardEvent) => { + const handleCopy = (event: ReactClipboardEvent) => { const selection = window.getSelection(); if (!selection || selection.isCollapsed || !event.clipboardData) return; const payload = chatMarkdownClipboardPayload(selection); @@ -2535,6 +669,7 @@ function useChatMarkdownState({ // synchronously whether to intercept its `_blank`, and a subscription is what // makes a persisted "app" apply once settings hydrate after launch. const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget); + const mathEnabled = useClientSettings((settings) => settings.mathRenderingEnabled); const resolveThreadPullRequest = ( href: string, ): (ThreadPullRequestKey & { readonly url: string }) | null => { @@ -2609,57 +744,77 @@ function useChatMarkdownState({ openPreview, }); }; - const findWorkspaceBasenameMatch = async (workspaceRelativePath: string) => { + const findWorkspaceFileMatch = async (fileLinkMeta: MarkdownFileLinkMeta) => { + const workspaceRelativePath = fileLinkMeta.workspaceLookupPath; if ( + !workspaceRelativePath || !cwd || environmentId === null || - !readEnvironmentScope(environmentId, AuthFilesystemReadScope) || - !needsWorkspaceBasenameLookup(workspaceRelativePath) + !readEnvironmentScope(environmentId, AuthFilesystemReadScope) ) { return null; } + const isBasename = needsWorkspaceBasenameLookup(workspaceRelativePath); + const strippedPath = isBasename + ? null + : stripRepeatedWorkspacePrefix(workspaceRelativePath, cwd); + if (!isBasename && strippedPath === null) return null; const result = await searchProjectEntries({ environmentId, input: { cwd, - query: workspaceRelativePath, + query: strippedPath ?? workspaceRelativePath, limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, kind: "file", }, }); - return result._tag === "Success" - ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) - : null; + if (result._tag !== "Success") return null; + if (strippedPath === null) { + return pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries); + } + const match = pickStrippedWorkspaceMatch(strippedPath, cwd, result.value.entries); + if (match === null) return null; + // Search can be truncated or still scanning, so it cannot rule out the + // literal path. Check the host before redirecting to the stripped match. + const metadata = await getFilesystemMetadata({ + environmentId, + input: { paths: [resolveWorkspaceFilePath(workspaceRelativePath, cwd)] }, + }); + return metadata._tag === "Success" && metadata.value.entries[0] === null ? match : null; }; - const openFileInPanel = (panelPath: string, line: number | undefined) => { + const openFileInPanel = (fileLinkMeta: MarkdownFileLinkMeta, panelPath: string) => { if (!threadRef) return; // Claimed on every open so a synchronous one supersedes a lookup already // in flight. const isLatestLookup = claimWorkspaceBasenameLookup(); - const openAt = (path: string) => useRightPanelStore.getState().openFile(threadRef, path, line); - if (!cwd || !needsWorkspaceBasenameLookup(panelPath)) { + const openAt = (path: string) => + useRightPanelStore.getState().openFile(threadRef, path, fileLinkMeta.line); + if ( + fileLinkMeta.workspaceLookupPath === null || + !cwd || + (!needsWorkspaceBasenameLookup(panelPath) && + stripRepeatedWorkspacePrefix(panelPath, cwd) === null) + ) { openAt(panelPath); return; } void (async () => { - const match = await findWorkspaceBasenameMatch(panelPath); + const match = await findWorkspaceFileMatch(fileLinkMeta); if (!isLatestLookup()) return; openAt(match ?? panelPath); })(); }; - const revealMarkdownFileInFileManager = async (fileLinkMeta: MarkdownFileLinkMeta) => { - const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; - const match = workspaceRelativePath - ? await findWorkspaceBasenameMatch(workspaceRelativePath) - : null; - const filePath = match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath; - return revealFileInFileManager(filePath); - }; const fileLinkChip = ( fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string, ) => { + const resolveHostPath = () => + resolveMarkdownFileHostPath({ + meta: fileLinkMeta, + cwd, + findWorkspaceMatch: () => findWorkspaceFileMatch(fileLinkMeta), + }); const mediaPath = mediaSource ?? fileLinkMeta.filePath; const canPreviewMedia = mediaMimeTypeFromExtension( @@ -2667,7 +822,7 @@ function useChatMarkdownState({ ) !== null; const panelPath = fileLinkMeta.workspaceRelativePath ?? - (!canPreviewMedia && isAbsolutePath(fileLinkMeta.filePath) ? fileLinkMeta.filePath : null); + (!canPreviewMedia && isHostPath(fileLinkMeta.filePath) ? fileLinkMeta.filePath : null); return ( + openInPreferredEditor( + markdownFileEditorTarget(fileLinkMeta, await resolveHostPath()), + ) + : undefined + } + resolveCopyPaths={async () => + markdownFileCopyPaths(fileLinkMeta, await resolveHostPath(), cwd) + } + onOpenInPanel={(path) => openFileInPanel(fileLinkMeta, path)} onOpenMedia={ threadRef && canPreviewMedia ? () => openMarkdownMedia(mediaPath, fileLinkMeta.filePath) @@ -2699,7 +864,7 @@ function useChatMarkdownState({ openInEditorMenuLabel={preferredEditorMenuLabel} onReveal={ canUseShellActions && revealInFileManagerLabel !== undefined - ? () => revealMarkdownFileInFileManager(fileLinkMeta) + ? async () => revealFileInFileManager((await resolveHostPath()).absolutePath) : undefined } revealLabel={revealInFileManagerLabel} @@ -2729,6 +894,8 @@ function useChatMarkdownState({ imageBaseDir, workspaceMutationId, inlineCodeFileLinkMetaByText, + inline, + mathEnabled, isStreaming, linkTargetPreference, markdownFileLinkMetaByHref, @@ -2811,7 +978,11 @@ const CHAT_MARKDOWN_COMPONENTS = { return
{children}
; } return ( -
+

+ ); + } if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); const fileLinkMeta = @@ -3302,9 +1482,15 @@ const CHAT_MARKDOWN_COMPONENTS = { return {children}; }, pre: function MarkdownPre({ node, children, ...props }) { - const { resolvedTheme, diffThemeName, expandMedia, isStreaming, onRunShellCommand } = use( - ChatMarkdownRendererContext, - ); + const { + resolvedTheme, + diffThemeName, + expandMedia, + isStreaming, + onRunShellCommand, + inline, + mathEnabled, + } = use(ChatMarkdownRendererContext); const text = use(ChatMarkdownTextContext); const codeBlock = extractCodeBlock(children); if (!codeBlock) { @@ -3312,6 +1498,15 @@ const CHAT_MARKDOWN_COMPONENTS = { } const language = extractFenceLanguage(codeBlock.className); + if ( + mathEnabled && + language === "math" && + (!isStreaming || + mathKind(codeBlock.className) === "display" || + isClosedCodeFence(node, text, codeBlock.code)) + ) { + return ; + } const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); const highlightedCode = ( expandMedia({ images: [{ src, name: "Mermaid diagram" }], index: 0 })} > {highlightedCode} @@ -3354,7 +1549,7 @@ const CHAT_MARKDOWN_COMPONENTS = { fenceTitle={fenceTitle} theme={resolvedTheme} onRunShellCommand={ - onRunShellCommand && !isStreaming && isClosedCodeFence(node, text) + onRunShellCommand && !isStreaming && isClosedCodeFence(node, text, codeBlock.code) ? onRunShellCommand : undefined } @@ -3369,6 +1564,7 @@ const CHAT_MARKDOWN_COMPONENTS = { function ChatMarkdown({ text, className, + inline = false, lineBreaks = false, parseRawHtml = true, extraRemarkPlugins = EMPTY_REMARK_PLUGINS, @@ -3381,22 +1577,30 @@ function ChatMarkdown({ markdownUrlTransform, localMediaPreview, closeLocalMediaPreview, - } = useChatMarkdownState({ text, ...props }); + } = useChatMarkdownState({ text, inline, ...props }); const incrementalParsing = props.isStreaming === true && extraRemarkPlugins.length === 0 && /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text); + const Root = inline ? "span" : "div"; + const bindMarkdownRef = (node: HTMLElement | null) => { + markdownRef.current = node; + }; const remarkPlugins = [ ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), ...extraRemarkPlugins, ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), ]; + if (componentState.mathEnabled) + remarkPlugins.unshift([remarkMath, { singleDollarTextMath: true }]); return ( -

) : null} -
+ ); } diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 89e9a99f1064..5c504175973c 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,117 +1,69 @@ import { + boundAttachmentIds, findRecordedWorktreeSetup, - isThreadWokeUnseen, mergeDirectPreviewAnnotation, - resolveVisibleWorktreeSetup, + planComposerTurn, + snapshotComposerSend, + withMessageContext, + deriveAvailableRevertTurnCounts, + deriveWorkspaceMutationId, } from "./ChatView.logic"; -import { - recallCheckoutIsRepo, - rememberCheckoutIsRepo, - threadShellHasStarted, -} from "./ChatView.logic"; -import { - ANTIGRAVITY_DEFAULT_MODEL, - ProviderDriverKind, - type ServerProvider, -} from "@t3tools/contracts"; -import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; -import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; -import { - selectActiveRightPanel, - selectThreadRightPanelState, - useRightPanelStore, - type RightPanelSurface, -} from "../rightPanelStore"; +import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic"; +import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { deriveProviderInstanceEntries } from "../providerInstances"; +import { ensureInlineContextReferences } from "../lib/composerContextReferences"; +import { terminalContextReference } from "../lib/composerContextRecords"; import { CommandId, EnvironmentId, - EventId, MessageId, ProjectId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInstanceId, + ProviderSessionId, + ProviderThreadId, ThreadId, RunId, - type ScopedThreadRef, TurnItemId, type OrchestrationV2ProjectedTurnItem, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; -import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { Atom, AsyncResult } from "effect/reactivity"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; -import type { Thread, TurnDiffSummary } from "../types"; +import type { Thread } from "../types"; import { makeThreadFixture, makeThreadProjectionFixture } from "../test-fixtures"; import { - agentControlledBrowserCloseConfirmation, - ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getAntigravitySendBlockReason, resolveBackgroundDraftWorkspaceOptions, resolvePrepareWorktreeBranchOptions, - resolveComposerInteractionMode, - restorePlanFollowUpComposer, resolveComposerProviderSelection, - resolveProactiveTurnDiffAction, - resolveDraftHeroState, - resolveExplicitDiffOpenOnActivate, resolveWorktreeSetupProgress, - isPaintOnlyThreadTimeline, - peekHeldThreadTimeline, peekRememberedThreadTimeline, rememberReadyThreadTimeline, resetHeldThreadTimeline, resolveThreadSwitchTimeline, threadKeysShareEnvironment, timelineHasEphemeralPreviewUrls, - scheduleEnvironmentReconnectWarning, - codexArtifactTemplatePromptToAppend, - shouldDockDraftHeroForSubmission, - shouldReleaseTimelineAnchorForToolActivity, - shouldRefocusComposerOnWindowFocus, - shouldOpenProactivePullRequest, - shouldRetargetThreadPullRequestPanel, - shouldOpenProactiveTurnDiff, - shouldRenderPreviewMiniPlayer, - MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - branchMismatchKey, - buildExpiredTerminalContextToastCopy, createLocalDispatchSnapshot, deriveCommittedServerUserMessageIds, deriveComposerSendState, deriveLockedProvider, - dismissBranchMismatchForSession, - getStartedThreadModelChangeBlockReason, - hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, - isBranchMismatchDismissedForSession, - reconcileMountedTerminalThreadIds, resolveDraftPromotionNavigationTarget, - resolveEffectiveInteractionMode, - resolveThreadMetadataUpdateForNextTurn, - resolveSendEnvMode, - startNewThreadForProject, - shouldShowBranchMismatchBanner, - shouldShowPlanFollowUpPrompt, - shouldWriteThreadErrorToCurrentServerThread, + shouldRetargetThreadPullRequestPanel, waitForRevertedMessage, - prepareRevertedMessageAttachments, } from "./ChatView.logic"; +import type { RightPanelSurface } from "../rightPanelStore"; const environmentId = EnvironmentId.make("environment-local"); const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; -const helloWorldTemplate: CodexArtifactTemplate = { - artifactKind: "document", - displayName: "Hello World", - skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world", - skillName: "artifact-template-hello-world", -}; function makeThread(overrides: Partial = {}): Thread { return makeThreadFixture({ @@ -200,112 +152,9 @@ describe("resolveDraftPromotionNavigationTarget", () => { }), ).toBeNull(); }); - - it("navigates once the run starts or startup stops", () => { - expect( - resolveDraftPromotionNavigationTarget({ - serverThreadRef, - serverThread: makeThread({ latestRun: completedTurn }), - backgroundSubmissionPending: false, - }), - ).toBe(serverThreadRef); - expect( - resolveDraftPromotionNavigationTarget({ - serverThreadRef, - serverThread: makeThread({ - latestRun: { ...preparingRun, status: "failed" as const }, - }), - backgroundSubmissionPending: false, - }), - ).toBe(serverThreadRef); - }); - - it("defers while a background submission is pending", () => { - expect( - resolveDraftPromotionNavigationTarget({ - serverThreadRef, - serverThread: makeThread({ latestRun: completedTurn }), - backgroundSubmissionPending: true, - }), - ).toBeNull(); - }); -}); - -describe("resolveEffectiveInteractionMode", () => { - it("forces build mode when legacy plan mode is disabled", () => { - expect( - resolveEffectiveInteractionMode({ - planModeEnabled: false, - composerInteractionMode: "plan", - threadInteractionMode: "plan", - }), - ).toBe("default"); - }); - - it("uses the saved mode while legacy plan mode is enabled", () => { - expect( - resolveEffectiveInteractionMode({ - planModeEnabled: true, - composerInteractionMode: null, - threadInteractionMode: "plan", - }), - ).toBe("plan"); - }); -}); - -describe("resolveThreadMetadataUpdateForNextTurn", () => { - const modelSelection = { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }; - - it("updates a stale local thread branch to the active checkout", () => { - expect( - resolveThreadMetadataUpdateForNextTurn({ - currentModelSelection: modelSelection, - currentBranch: "feature/thread", - nextBranch: "feature/checkout", - }), - ).toEqual({ branch: "feature/checkout", worktreePath: null }); - }); - - it("does not write metadata when the model and branch are unchanged", () => { - expect( - resolveThreadMetadataUpdateForNextTurn({ - currentModelSelection: modelSelection, - nextModelSelection: modelSelection, - currentBranch: "feature/current", - nextBranch: "feature/current", - }), - ).toBeNull(); - }); }); describe("deriveComposerSendState", () => { - it("treats expired terminal pills as non-sendable content", () => { - const state = deriveComposerSendState({ - prompt: "[Terminal 1](t3-context://v1/terminal/ctx-expired)", - imageCount: 0, - terminalContexts: [ - { - id: "ctx-expired", - threadId, - terminalId: "default", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - createdAt: now, - }, - ], - }); - - expect(state.trimmedPrompt).toBe(""); - expect(state.sendableTerminalContexts).toEqual([]); - expect(state.expiredTerminalContextCount).toBe(1); - expect(state.hasSendableContent).toBe(false); - }); - it("keeps text sendable while excluding expired terminal pills", () => { const state = deriveComposerSendState({ prompt: `yoo [Terminal 1](t3-context://v1/terminal/ctx-expired) waddup`, @@ -328,295 +177,9 @@ describe("deriveComposerSendState", () => { expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(true); }); - - it("treats element contexts as sendable content (no text, no images, no terminals)", () => { - const state = deriveComposerSendState({ - prompt: "", - imageCount: 0, - terminalContexts: [], - elementContextCount: 1, - }); - - expect(state.trimmedPrompt).toBe(""); - expect(state.expiredTerminalContextCount).toBe(0); - expect(state.hasSendableContent).toBe(true); - }); - - it("does NOT treat zero element contexts as sendable", () => { - expect( - deriveComposerSendState({ - prompt: "", - imageCount: 0, - terminalContexts: [], - elementContextCount: 0, - }).hasSendableContent, - ).toBe(false); - }); -}); - -describe("buildExpiredTerminalContextToastCopy", () => { - it("formats empty and omission guidance", () => { - expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ - title: "Expired terminal context won't be sent", - description: "Remove it or re-add it to include terminal output.", - }); - expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ - title: "Expired terminal contexts omitted from message", - description: "Re-add it if you want that terminal output included.", - }); - }); -}); - -describe("getStartedThreadModelChangeBlockReason", () => { - const providers = [ - { - instanceId: ProviderInstanceId.make("codex"), - }, - { - instanceId: ProviderInstanceId.make("grok"), - requiresNewThreadForModelChange: true, - }, - ]; - - it("allows model changes before a provider session has started", () => { - expect( - getStartedThreadModelChangeBlockReason({ - providers, - hasStartedSession: false, - currentModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-build", - }, - nextModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-other", - }, - }), - ).toBeNull(); - }); - - it("allows unchanged model selections for restricted providers", () => { - expect( - getStartedThreadModelChangeBlockReason({ - providers, - hasStartedSession: true, - currentModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-build", - }, - nextModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-build", - }, - }), - ).toBeNull(); - }); - - it("blocks started-session model changes for providers that require a new thread", () => { - expect( - getStartedThreadModelChangeBlockReason({ - providers, - hasStartedSession: true, - currentModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-build", - }, - nextModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-other", - }, - }), - ).toEqual({ - title: "Start a new chat to change models", - description: - "This provider does not allow switching models after a conversation has started.", - }); - }); -}); - -describe("resolveSendEnvMode", () => { - it("keeps worktree mode only for git repositories", () => { - expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree"); - expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local"); - }); -}); - -describe("branchMismatchKey", () => { - it("builds a key from thread id and both branches", () => { - expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe( - "thread-1:feat/a:feat/b", - ); - }); - - it("returns null without a thread or mismatch", () => { - expect(branchMismatchKey(null, { threadBranch: "a", currentBranch: "b" })).toBeNull(); - expect(branchMismatchKey("thread-1", null)).toBeNull(); - }); -}); - -describe("shouldShowBranchMismatchBanner", () => { - const base = { - hasMismatch: true, - isDismissed: false, - composerHasContent: false, - wasShownForCurrentMismatch: false, - }; - - it("stays hidden during passive browsing (even though the composer autofocuses)", () => { - expect(shouldShowBranchMismatchBanner(base)).toBe(false); - }); - - it("shows once the composer has draft content", () => { - expect(shouldShowBranchMismatchBanner({ ...base, composerHasContent: true })).toBe(true); - }); - - it("stays mounted after the draft clears once shown for the current mismatch", () => { - expect(shouldShowBranchMismatchBanner({ ...base, wasShownForCurrentMismatch: true })).toBe( - true, - ); - }); - - it("never shows when dismissed or without a mismatch", () => { - expect( - shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, isDismissed: true }), - ).toBe(false); - expect( - shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, hasMismatch: false }), - ).toBe(false); - }); -}); - -describe("shouldShowPlanFollowUpPrompt", () => { - const base = { - pendingUserInputCount: 0, - interactionMode: "plan" as const, - latestTurnSettled: true, - hasActionableProposedPlan: true, - hasComposerAttachments: false, - }; - - it("shows plan actions for a settled actionable plan without attachments", () => { - expect(shouldShowPlanFollowUpPrompt(base)).toBe(true); - }); - - it("hides plan actions while the composer has staged attachments", () => { - expect(shouldShowPlanFollowUpPrompt({ ...base, hasComposerAttachments: true })).toBe(false); - }); - - it("preserves the existing plan follow-up gates", () => { - expect(shouldShowPlanFollowUpPrompt({ ...base, pendingUserInputCount: 1 })).toBe(false); - expect(shouldShowPlanFollowUpPrompt({ ...base, interactionMode: "default" })).toBe(false); - expect(shouldShowPlanFollowUpPrompt({ ...base, latestTurnSettled: false })).toBe(false); - expect(shouldShowPlanFollowUpPrompt({ ...base, hasActionableProposedPlan: false })).toBe(false); - }); -}); - -describe("session branch mismatch dismissal", () => { - it("tracks dismissed keys and treats other keys as active", () => { - expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false); - dismissBranchMismatchForSession("t1:a:b"); - expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(true); - expect(isBranchMismatchDismissedForSession("t1:a:c")).toBe(false); - expect(isBranchMismatchDismissedForSession(null)).toBe(false); - }); -}); - -describe("reconcileMountedTerminalThreadIds", () => { - it("keeps open threads and makes the active thread most recent", () => { - expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: ["thread-a", "thread-b", "thread-c"], - openThreadIds: ["thread-a", "thread-b", "thread-c"], - activeThreadId: "thread-a", - activeThreadTerminalOpen: true, - maxHiddenThreadCount: 2, - }), - ).toEqual(["thread-b", "thread-c", "thread-a"]); - }); - - it("drops closed threads and enforces the hidden mounted cap", () => { - const ids = Array.from( - { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 }, - (_, index) => `thread-${index}`, - ); - expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: ids, - openThreadIds: ids.slice(1), - activeThreadId: null, - activeThreadTerminalOpen: false, - }), - ).toEqual(ids.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS)); - }); -}); - -describe("shouldWriteThreadErrorToCurrentServerThread", () => { - it("requires the environment, route thread, and target thread to match", () => { - const routeThreadRef = { environmentId, threadId }; - - expect( - shouldWriteThreadErrorToCurrentServerThread({ - serverThread: { environmentId, id: threadId }, - routeThreadRef, - targetThreadId: threadId, - }), - ).toBe(true); - expect( - shouldWriteThreadErrorToCurrentServerThread({ - serverThread: null, - routeThreadRef, - targetThreadId: threadId, - }), - ).toBe(false); - }); -}); - -describe("startNewThreadForProject", () => { - it("starts a thread through the supplied shared handler for the active project", () => { - const calls: Array<{ environmentId: EnvironmentId; projectId: ProjectId }> = []; - const projectRef = { environmentId, projectId }; - - expect( - startNewThreadForProject(projectRef, (nextProjectRef) => { - calls.push(nextProjectRef); - return Promise.resolve(); - }), - ).toBe(true); - expect(calls).toEqual([projectRef]); - }); - - it("does nothing when the active project is unavailable", () => { - let called = false; - - expect( - startNewThreadForProject(null, () => { - called = true; - return Promise.resolve(); - }), - ).toBe(false); - expect(called).toBe(false); - }); }); describe("hasServerAcknowledgedLocalDispatch", () => { - it("does not acknowledge unchanged server state", () => { - const localDispatch = createLocalDispatchSnapshot( - makeThread({ latestRun: completedTurn, runtime: readySession }), - ); - - expect( - hasServerAcknowledgedLocalDispatch({ - localDispatch, - phase: "ready", - latestRun: completedTurn, - runtime: readySession, - hasPendingApproval: false, - hasPendingUserInput: false, - threadError: null, - }), - ).toBe(false); - }); - it("acknowledges a settled newer background turn", () => { const localDispatch = createLocalDispatchSnapshot( makeThread({ latestRun: completedTurn, runtime: readySession }), @@ -739,23 +302,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => { }), ).toBe(true); }); - - it("acknowledges pending user interaction and errors immediately", () => { - const localDispatch = createLocalDispatchSnapshot(makeThread()); - const common = { - localDispatch, - phase: "ready" as const, - latestRun: null, - runtime: null, - hasPendingApproval: false, - hasPendingUserInput: false, - threadError: null, - }; - - expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingApproval: true })).toBe(true); - expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true); - expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); - }); }); describe("deriveCommittedServerUserMessageIds", () => { @@ -858,305 +404,41 @@ describe("deriveCommittedServerUserMessageIds", () => { }); }); -describe("agent browser close confirmation", () => { - const surfaces = [ - { id: "browser:one", kind: "preview", resourceId: "tab-1" }, - { id: "browser:two", kind: "preview", resourceId: "tab-2" }, - { id: "diff", kind: "diff" }, - ] satisfies RightPanelSurface[]; +describe("resolveThreadSwitchTimeline", () => { + afterEach(() => { + resetHeldThreadTimeline(); + }); - it("only warns for browsers under active agent control", () => { - expect( - agentControlledBrowserCloseConfirmation(surfaces, { - "tab-1": { controller: "none" }, - "tab-2": { controller: "human" }, - }), - ).toBeNull(); + const held = { threadKey: "env-1:thread-a", entries: ["a1", "a2"] }; + it("paints a remembered destination instead of the last-viewed thread", () => { + rememberReadyThreadTimeline(held); + rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["b1", "b2"] }); + expect(peekRememberedThreadTimeline("env-1:thread-a")).toEqual(["a1", "a2"]); expect( - agentControlledBrowserCloseConfirmation([surfaces[0]!], { - "tab-1": { controller: "agent" }, + resolveThreadSwitchTimeline({ + loading: true, + activeThreadKey: "env-1:thread-a", + nextEntries: [], }), - ).toBe( - [ - "Close browser while the agent is using it?", - "The agent is actively controlling this browser. Closing it may interrupt the current browser action.", - ].join("\n"), - ); + ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); }); - it("counts every agent-controlled browser in a bulk close", () => { + it("does not hold another environment's timeline across a jump", () => { + expect(threadKeysShareEnvironment("env-1:thread-a", "env-2:thread-b")).toBe(false); expect( - agentControlledBrowserCloseConfirmation(surfaces, { - "tab-1": { controller: "agent" }, - "tab-2": { controller: "agent" }, + resolveThreadSwitchTimeline({ + loading: true, + activeThreadKey: "env-2:thread-b", + nextEntries: [], + lastReady: held, }), - ).toContain("Close 2 browsers"); + ).toEqual({ entries: [], displayThreadKey: "env-2:thread-b" }); }); -}); -describe("floating browser preview", () => { - it("only hides the duplicate while the same browser is rendered in the panel", () => { - expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false); - expect( - shouldRenderPreviewMiniPlayer( - { kind: "browser", tabId: "tab-1" }, - { - id: "browser:one", - kind: "preview", - resourceId: "tab-1", - }, - ), - ).toBe(false); + it("does not remember a timeline that still has handoff blob previews", () => { expect( - shouldRenderPreviewMiniPlayer( - { kind: "browser", tabId: "tab-1" }, - { - id: "browser:two", - kind: "preview", - resourceId: "tab-2", - }, - ), - ).toBe(true); - expect( - shouldRenderPreviewMiniPlayer( - { kind: "browser", tabId: "tab-1" }, - { id: "diff", kind: "diff" }, - ), - ).toBe(true); - }); -}); - -describe("proactive panels", () => { - it("opens an existing pull request on entry and follows newly observed links", () => { - expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(true); - expect(shouldOpenProactivePullRequest(null, "project:repo:42")).toBe(true); - expect(shouldOpenProactivePullRequest("project:repo:42", "project:repo:42")).toBe(false); - expect(shouldOpenProactivePullRequest("project:repo:42", null)).toBe(false); - }); - - it("opens a completed diff on entry or when the observed running turn settles", () => { - const turnId = RunId.make("turn-1"); - expect( - shouldOpenProactiveTurnDiff({ - previousRunningTurnId: undefined, - runningTurnId: null, - settledTurnId: turnId, - turnCompleted: true, - }), - ).toBe(true); - expect( - shouldOpenProactiveTurnDiff({ - previousRunningTurnId: turnId, - runningTurnId: null, - settledTurnId: turnId, - turnCompleted: true, - }), - ).toBe(true); - expect( - shouldOpenProactiveTurnDiff({ - previousRunningTurnId: turnId, - runningTurnId: RunId.make("turn-2"), - settledTurnId: turnId, - turnCompleted: true, - }), - ).toBe(false); - expect( - shouldOpenProactiveTurnDiff({ - previousRunningTurnId: turnId, - runningTurnId: null, - settledTurnId: turnId, - turnCompleted: false, - }), - ).toBe(false); - }); -}); - -describe("artifact template composer insertion", () => { - it("does not insert an already-present prompt", () => { - const prompt = "Create a document using this $artifact-template-hello-world about…"; - - expect(codexArtifactTemplatePromptToAppend(prompt, helloWorldTemplate)).toBeNull(); - }); -}); - -describe("draft hero submission transition", () => { - it("does not dock the composer before a background submission", () => { - expect( - shouldDockDraftHeroForSubmission({ - isDraftHeroState: true, - activeThreadKey: "environment-local:thread-1", - submissionIntent: "background", - }), - ).toBe(false); - }); - - it("leaves the hero layout while a worktree setup card is on the timeline", () => { - expect( - resolveDraftHeroState({ - isLocalDraftThread: true, - hasTimelineEntries: false, - isWorking: false, - draftHeroDockRequested: false, - backgroundSubmissionPending: false, - hasWorktreeSetupCard: true, - }), - ).toBe(false); - expect( - resolveDraftHeroState({ - isLocalDraftThread: true, - hasTimelineEntries: false, - isWorking: false, - draftHeroDockRequested: false, - backgroundSubmissionPending: true, - hasWorktreeSetupCard: true, - }), - ).toBe(false); - }); - - it("keeps the composer in the hero layout until navigation after server promotion", () => { - expect( - resolveDraftHeroState({ - isLocalDraftThread: false, - hasTimelineEntries: true, - isWorking: true, - draftHeroDockRequested: false, - backgroundSubmissionPending: true, - }), - ).toBe(true); - }); - - it("does not auto-navigate a background submission after server promotion", () => { - expect( - resolveDraftPromotionNavigationTarget({ - serverThreadRef: { environmentId, threadId }, - serverThread: makeThread({ latestRun: completedTurn }), - backgroundSubmissionPending: true, - }), - ).toBeNull(); - }); -}); - -describe("resolveThreadSwitchTimeline", () => { - afterEach(() => { - resetHeldThreadTimeline(); - }); - - const held = { threadKey: "env-1:thread-a", entries: ["a1", "a2"] }; - - it("keeps the previous thread's entries while the next thread is loading", () => { - expect( - resolveThreadSwitchTimeline({ - loading: true, - activeThreadKey: "env-1:thread-b", - nextEntries: [], - lastReady: held, - }), - ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); - }); - - it("shows the new thread once its detail is ready", () => { - expect( - resolveThreadSwitchTimeline({ - loading: false, - activeThreadKey: "env-1:thread-b", - nextEntries: ["b1"], - lastReady: held, - }), - ).toEqual({ entries: ["b1"], displayThreadKey: "env-1:thread-b" }); - }); - - it("does not invent a timeline on the first open of a thread", () => { - expect( - resolveThreadSwitchTimeline({ - loading: true, - activeThreadKey: "env-1:thread-a", - nextEntries: [], - lastReady: null, - }), - ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); - }); - - it("keeps the held thread workspace cwd with the snapshot", () => { - rememberReadyThreadTimeline({ - ...held, - markdownCwd: "/repo/a", - workspaceRoot: "/repo/a", - }); - expect(peekHeldThreadTimeline()).toEqual({ - ...held, - markdownCwd: "/repo/a", - workspaceRoot: "/repo/a", - }); - }); - - it("survives a ChatView remount by remembering the last ready timeline", () => { - rememberReadyThreadTimeline(held); - expect(peekHeldThreadTimeline()).toEqual(held); - expect( - resolveThreadSwitchTimeline({ - loading: true, - activeThreadKey: "env-1:thread-b", - nextEntries: [], - }), - ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); - }); - - it("paints a remembered destination instead of the last-viewed thread", () => { - rememberReadyThreadTimeline(held); - rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["b1", "b2"] }); - expect(peekRememberedThreadTimeline("env-1:thread-a")).toEqual(["a1", "a2"]); - expect( - resolveThreadSwitchTimeline({ - loading: true, - activeThreadKey: "env-1:thread-a", - nextEntries: [], - }), - ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); - }); - - it("prefers live entries over a remembered snapshot", () => { - rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["stale-b"] }); - expect( - resolveThreadSwitchTimeline({ - loading: false, - activeThreadKey: "env-1:thread-b", - nextEntries: ["fresh-b"], - }), - ).toEqual({ entries: ["fresh-b"], displayThreadKey: "env-1:thread-b" }); - }); - - it("does not keep a remembered snapshot on a resolved empty thread", () => { - rememberReadyThreadTimeline(held); - expect( - resolveThreadSwitchTimeline({ - loading: false, - activeThreadKey: "env-1:thread-a", - nextEntries: [], - }), - ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); - }); - - it("does not hold another environment's timeline across a jump", () => { - expect(threadKeysShareEnvironment("env-1:thread-a", "env-2:thread-b")).toBe(false); - expect( - resolveThreadSwitchTimeline({ - loading: true, - activeThreadKey: "env-2:thread-b", - nextEntries: [], - lastReady: held, - }), - ).toEqual({ entries: [], displayThreadKey: "env-2:thread-b" }); - }); - - it("treats a foreign held timeline as paint-only", () => { - expect(isPaintOnlyThreadTimeline("env-1:thread-a", "env-1:thread-b")).toBe(true); - expect(isPaintOnlyThreadTimeline("env-1:thread-b", "env-1:thread-b")).toBe(false); - }); - - it("does not remember a timeline that still has handoff blob previews", () => { - expect( - timelineHasEphemeralPreviewUrls([ + timelineHasEphemeralPreviewUrls([ { kind: "message", message: { @@ -1210,150 +492,6 @@ describe("resolveThreadSwitchTimeline", () => { }); }); -describe("shouldReleaseTimelineAnchorForToolActivity", () => { - const activeTurnId = RunId.make("active-turn"); - const anchorMessageId = MessageId.make("anchored-message"); - const activeToolEntry = { - id: "tool-entry", - kind: "work" as const, - createdAt: now, - entry: { - id: "active-tool", - createdAt: now, - runId: activeTurnId, - label: "Run command", - tone: "tool" as const, - command: "git status", - }, - }; - - it("releases the send anchor for tool activity in the active turn", () => { - expect( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId, - liveFollowEnabled: true, - runningTurnId: activeTurnId, - timelineEntries: [activeToolEntry], - }), - ).toBe(true); - }); - - it("keeps the anchor while the user reads history", () => { - expect( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId, - liveFollowEnabled: false, - runningTurnId: activeTurnId, - timelineEntries: [activeToolEntry], - }), - ).toBe(false); - }); - - it("ignores tool activity from earlier turns", () => { - expect( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId, - liveFollowEnabled: true, - runningTurnId: activeTurnId, - timelineEntries: [ - { - ...activeToolEntry, - entry: { - ...activeToolEntry.entry, - runId: RunId.make("previous-turn"), - }, - }, - ], - }), - ).toBe(false); - }); - - it("ignores thinking and error rows without tool activity", () => { - expect( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId, - liveFollowEnabled: true, - runningTurnId: activeTurnId, - timelineEntries: [ - { - ...activeToolEntry, - entry: { - id: "thinking-entry", - createdAt: now, - runId: activeTurnId, - label: "Thinking", - tone: "thinking", - }, - }, - { - ...activeToolEntry, - id: "error-entry", - entry: { - id: "error-entry", - createdAt: now, - runId: activeTurnId, - label: "Provider error", - tone: "error", - }, - }, - ], - }), - ).toBe(false); - }); - - it("does nothing without an anchor or running turn", () => { - const input = { - anchorMessageId, - liveFollowEnabled: true, - runningTurnId: activeTurnId, - timelineEntries: [activeToolEntry], - }; - - expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe( - false, - ); - expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe( - false, - ); - }); -}); - -describe("environment reconnect warning grace", () => { - afterEach(() => vi.useRealTimers()); - - it("shows a persistent reconnect after the grace period", () => { - vi.useFakeTimers(); - const showWarning = vi.fn(); - - scheduleEnvironmentReconnectWarning(showWarning); - vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS - 1); - expect(showWarning).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(1); - expect(showWarning).toHaveBeenCalledOnce(); - }); - - it("cancels the warning when the connection recovers during the grace period", () => { - vi.useFakeTimers(); - const showWarning = vi.fn(); - - const cancel = scheduleEnvironmentReconnectWarning(showWarning); - cancel(); - vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); - - expect(showWarning).not.toHaveBeenCalled(); - }); - - it("does not reuse elapsed grace from another environment", () => { - const anotherEnvironmentId = EnvironmentId.make("environment-remote"); - - expect(hasEnvironmentReconnectWarningGraceElapsed(environmentId, environmentId)).toBe(true); - expect(hasEnvironmentReconnectWarningGraceElapsed(anotherEnvironmentId, environmentId)).toBe( - false, - ); - }); -}); - describe("resolveComposerProviderSelection", () => { const catalogModels: ServerProvider["models"] = [ { slug: "gemini-pro", name: "Gemini Pro", isCustom: false, capabilities: null }, @@ -1460,68 +598,6 @@ describe("resolveComposerProviderSelection", () => { }, ); - it("leaves a new draft free to select a different driver", () => { - const original = entry("claudeAgent", "claude_work"); - const selected = entry("codex", "codex_work"); - expect( - deriveLockedProvider({ - thread: makeThread({ - modelSelection: { instanceId: original.instanceId, model: "default" }, - }), - selectedProvider: selected.instanceId, - threadProvider: original.instanceId, - providers: [original.snapshot, selected.snapshot], - }), - ).toBeNull(); - }); - - it("uses the custom instance's capability instead of the default instance", () => { - const defaultEntry = entry("antigravity", "antigravity", { - showInteractionModeToggle: true, - }); - const customEntry = entry("antigravity", "google_work", { - showInteractionModeToggle: false, - }); - const selection = resolveComposerProviderSelection({ - entries: [defaultEntry, customEntry], - candidateInstanceIds: [customEntry.instanceId], - lockedProvider: null, - lockedInstanceId: null, - }); - - expect(selection.selectedProviderEntry?.instanceId).toBe(customEntry.instanceId); - expect( - resolveComposerInteractionMode({ - provider: selection.selectedProviderEntry?.snapshot, - planModeEnabled: true, - interactionMode: "plan", - }), - ).toEqual({ enabled: false, interactionMode: "default" }); - }); - - it("uses the fallback provider's plan capability after the draft's instance is disabled", () => { - const disabledEntry = entry("antigravity", "antigravity", { - enabled: false, - showInteractionModeToggle: false, - }); - const fallbackEntry = entry("codex"); - const selection = resolveComposerProviderSelection({ - entries: [disabledEntry, fallbackEntry], - candidateInstanceIds: [disabledEntry.instanceId], - lockedProvider: null, - lockedInstanceId: null, - }); - - expect(selection.selectedProviderEntry?.instanceId).toBe(fallbackEntry.instanceId); - expect( - resolveComposerInteractionMode({ - provider: selection.selectedProviderEntry?.snapshot, - planModeEnabled: true, - interactionMode: "plan", - }), - ).toEqual({ enabled: true, interactionMode: "plan" }); - }); - it("keeps a signed-out selection instead of silently switching providers", () => { const signedOutEntry = entry("antigravity", "google_work", { status: "error", @@ -1541,48 +617,6 @@ describe("resolveComposerProviderSelection", () => { ).toBe("Sign in to Antigravity in provider settings before sending."); }); - it("blocks sends until the selected Antigravity profile is installed", () => { - const provider = entry("antigravity", "google_work", { - installed: false, - models: catalogModels, - }).snapshot; - - expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBe( - "Install Antigravity in provider settings before sending.", - ); - }); - - it("lets Antigravity check saved credentials when resuming after a restart", () => { - const provider = entry("antigravity", "google_work", { - status: "warning", - auth: { status: "unknown" }, - models: [], - }).snapshot; - - expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBeNull(); - expect(getAntigravitySendBlockReason(provider, ANTIGRAVITY_DEFAULT_MODEL)).toBeNull(); - expect( - getAntigravitySendBlockReason({ ...provider, models: catalogModels }, "gemini-pro"), - ).toBeNull(); - expect(getAntigravitySendBlockReason(provider, "")).toBe( - "Choose an Antigravity model before sending.", - ); - }); - - it("blocks saved model sends until Antigravity loads its account catalog", () => { - expect(getAntigravitySendBlockReason(entry("antigravity").snapshot, "gemini-pro")).toBe( - "Refresh Antigravity models in provider settings before sending.", - ); - }); - - it("blocks an empty Antigravity selection after the catalog has loaded", () => { - const provider = entry("antigravity", "google_work", { models: catalogModels }).snapshot; - - expect(getAntigravitySendBlockReason(provider, "")).toBe( - "Choose an Antigravity model before sending.", - ); - }); - it("blocks a saved model that a ready catalog no longer lists", () => { const provider = entry("antigravity", "google_work", { status: "ready", @@ -1606,16 +640,6 @@ describe("resolveComposerProviderSelection", () => { ).toBeNull(); }); - it("keeps existing send behavior for other providers", () => { - const provider = entry("codex", "codex", { - installed: false, - auth: { status: "unknown" }, - models: [], - }).snapshot; - - expect(getAntigravitySendBlockReason(provider, "gpt-model")).toBeNull(); - }); - it("does not continue an existing Antigravity thread in another profile after deletion", () => { const missingInstanceId = ProviderInstanceId.make("google_work"); const selection = resolveComposerProviderSelection({ @@ -1629,18 +653,6 @@ describe("resolveComposerProviderSelection", () => { expect(selection.unavailableProviderInstanceId).toBe(missingInstanceId); }); - it("does not treat the empty draft placeholder as a provider setup target", () => { - const selection = resolveComposerProviderSelection({ - entries: [entry("antigravity", "antigravity", { enabled: false })], - candidateInstanceIds: [NO_PROVIDER_MODEL_SELECTION.instanceId], - lockedProvider: null, - lockedInstanceId: null, - }); - - expect(selection.selectedProviderEntry).toBeUndefined(); - expect(selection.unavailableProviderInstanceId).toBeUndefined(); - }); - it("keeps the session's continuation group when another instance was selected", () => { const sessionEntry = entry("antigravity", "google_work", { enabled: false, @@ -1660,63 +672,24 @@ describe("resolveComposerProviderSelection", () => { }); }); -describe("resolveComposerInteractionMode", () => { - it("resets a restored plan draft when the selected instance does not support plan mode", () => { - expect( - resolveComposerInteractionMode({ - planModeEnabled: true, - provider: { showInteractionModeToggle: false }, - interactionMode: "plan", - }), - ).toEqual({ enabled: false, interactionMode: "default" }); - }); - - it("keeps legacy plan behavior for providers that omit the capability", () => { - expect( - resolveComposerInteractionMode({ - planModeEnabled: true, - provider: {}, - interactionMode: "plan", - }), - ).toEqual({ enabled: true, interactionMode: "plan" }); - }); - - it("resets a restored plan draft when the beta setting is off", () => { - expect( - resolveComposerInteractionMode({ - planModeEnabled: false, - provider: { showInteractionModeToggle: true }, - interactionMode: "plan", - }), - ).toEqual({ enabled: false, interactionMode: "default" }); - }); - - it("disables plan mode until the selected provider is available", () => { - expect( - resolveComposerInteractionMode({ - planModeEnabled: true, - provider: null, - interactionMode: "plan", - }), - ).toEqual({ enabled: false, interactionMode: "default" }); - }); -}); - describe("resolvePrepareWorktreeBranchOptions", () => { - it.each(["origin", "upstream", null] as const)( - "starts a new branch from remote %s", - (startFromRemote) => { + it.each([ + { startFromOrigin: true, expected: { startFromOrigin: true } }, + { startFromOrigin: false, expected: {} }, + ])( + "starts a new branch with startFromOrigin=$startFromOrigin", + ({ startFromOrigin, expected }) => { expect( - resolvePrepareWorktreeBranchOptions({ createNewBranch: true, startFromRemote }), - ).toEqual({ startFromRemote }); + resolvePrepareWorktreeBranchOptions({ createNewBranch: true, startFromOrigin }), + ).toStrictEqual(expected); }, ); - it.each(["origin", "upstream", null] as const)( - "never sends start remote %s when reusing an existing branch", - (startFromRemote) => { + it.each([true, false])( + "never starts from a remote when reusing an existing branch (startFromOrigin=%s)", + (startFromOrigin) => { expect( - resolvePrepareWorktreeBranchOptions({ createNewBranch: false, startFromRemote }), + resolvePrepareWorktreeBranchOptions({ createNewBranch: false, startFromOrigin }), ).toStrictEqual({ createBranch: false }); }, ); @@ -1725,26 +698,23 @@ describe("resolvePrepareWorktreeBranchOptions", () => { describe("resolveBackgroundDraftWorkspaceOptions", () => { it.each( [true, false].flatMap((createNewBranch) => - (["origin", "upstream", null] as const).map((startFromRemote) => ({ - createNewBranch, - startFromRemote, - })), + [true, false].map((startFromOrigin) => ({ createNewBranch, startFromOrigin })), ), )( - "keeps New worktree, its starting remote $startFromRemote, and only a reusable base branch (createNewBranch=$createNewBranch)", - ({ createNewBranch, startFromRemote }) => { + "keeps New worktree, startFromOrigin=$startFromOrigin, and only a reusable base branch (createNewBranch=$createNewBranch)", + ({ createNewBranch, startFromOrigin }) => { expect( resolveBackgroundDraftWorkspaceOptions({ envMode: "worktree", branch: "main", - startFromRemote, + startFromOrigin, createNewBranch, }), ).toEqual({ envMode: "worktree", branch: createNewBranch ? "main" : null, worktreePath: null, - startFromRemote, + startFromOrigin, createNewBranch, }); }, @@ -1755,364 +725,26 @@ describe("resolveBackgroundDraftWorkspaceOptions", () => { resolveBackgroundDraftWorkspaceOptions({ envMode: "local", branch: "feature/existing", - startFromRemote: "upstream", + startFromOrigin: true, createNewBranch: false, }), ).toEqual({ envMode: "local", branch: "feature/existing", worktreePath: null, - startFromRemote: null, + startFromOrigin: false, createNewBranch: false, }); }); }); -describe("proactive completed diff guard", () => { - it.each([ - { files: 0, additions: 0, deletions: 0, action: "ignore" }, - { files: 1, additions: 1, deletions: 0, action: "ignore" }, - { files: 2, additions: 12, deletions: 12, action: "ignore" }, - { files: 1, additions: 25, deletions: 24, action: "ignore" }, - { files: 1, additions: 25, deletions: 25, action: "open" }, - { files: 1, additions: 0, deletions: 50, action: "open" }, - { files: 3, additions: 1, deletions: 0, action: "open" }, - ])( - "uses change size for automatic diffs: $files files, +$additions/-$deletions", - ({ files, additions, deletions, action }) => { - const changedCheckpoint = { - status: "ready", - files: Array.from({ length: files }, (_, index) => ({ - path: `src/app-${index}.ts`, - kind: "modified" as const, - additions, - deletions, - })), - } satisfies Pick; - - expect( - resolveProactiveTurnDiffAction({ - checkpoint: changedCheckpoint, - isGitRepo: true, - activeSurfaceKind: null, - }), - ).toBe(action); - }, - ); - - it("waits for definitive checkpoint and repository state", () => { - const missingCheckpoint = { - status: "missing", - files: [], - } satisfies Pick; - const changedCheckpoint = { - status: "ready", - files: [{ path: "src/app.ts", kind: "modified", additions: 1, deletions: 0 }], - } satisfies Pick; - - expect( - resolveProactiveTurnDiffAction({ - checkpoint: undefined, - isGitRepo: true, - activeSurfaceKind: null, - }), - ).toBe("defer"); - expect( - resolveProactiveTurnDiffAction({ - checkpoint: missingCheckpoint, - isGitRepo: true, - activeSurfaceKind: null, - }), - ).toBe("defer"); - expect( - resolveProactiveTurnDiffAction({ - checkpoint: changedCheckpoint, - isGitRepo: undefined, - activeSurfaceKind: null, - }), - ).toBe("defer"); - }); - - it("keeps an active pull request above a completed turn diff", () => { - const changedCheckpoint = { - status: "ready", - files: [{ path: "src/app.ts", kind: "modified", additions: 1, deletions: 0 }], - } satisfies Pick; - - expect( - resolveProactiveTurnDiffAction({ - checkpoint: changedCheckpoint, - isGitRepo: true, - activeSurfaceKind: "pull-request", - }), - ).toBe("ignore"); - }); - - it("leaves an already open diff and its chosen scope alone", () => { - const largeCheckpoint = { - status: "ready", - files: Array.from({ length: 3 }, (_, index) => ({ - path: `src/app-${index}.ts`, - kind: "modified" as const, - additions: 20, - deletions: 0, - })), - } satisfies Pick; - - expect( - resolveProactiveTurnDiffAction({ - checkpoint: largeCheckpoint, - isGitRepo: true, - activeSurfaceKind: "diff", - }), - ).toBe("ignore"); - }); -}); - -describe("Diff scope across right-panel tab switches", () => { - const threadRef: ScopedThreadRef = { environmentId, threadId }; - const otherThreadRef: ScopedThreadRef = { environmentId, threadId: ThreadId.make("thread-2") }; - let explicitDiffOpen: ScopedThreadRef | null = null; - - const diffIsActive = (ref: ScopedThreadRef) => - selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, ref) === "diff"; - const diffScope = (ref: ScopedThreadRef) => - selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, ref).kind; - const settle = (ref: ScopedThreadRef) => { - const explicit = explicitDiffOpen; - explicitDiffOpen = null; - if (diffIsActive(ref) && explicit !== ref) { - useDiffPanelStore.getState().selectGitScope(ref, "branch"); - } - }; - const clickTab = (ref: ScopedThreadRef, surfaceId: string) => { - const surface = selectThreadRightPanelState( - useRightPanelStore.getState().byThreadKey, - ref, - ).surfaces.find((entry) => entry.id === surfaceId); - explicitDiffOpen = resolveExplicitDiffOpenOnActivate({ - surfaceKind: surface!.kind, - diffOpen: diffIsActive(ref), - activeThreadRef: ref, - }); - useRightPanelStore.getState().activateSurface(ref, surfaceId); - settle(ref); - }; - - beforeEach(() => { - explicitDiffOpen = null; - useRightPanelStore.setState({ - byThreadKey: {}, - userActionRevisionByThreadKey: {}, - closeRevisionByThreadKey: {}, - }); - useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {} }); - }); - - it("keeps Uncommitted when the user clicks back to the Diff tab", () => { - const panels = useRightPanelStore.getState(); - panels.open(threadRef, "diff"); - settle(threadRef); - useDiffPanelStore.getState().selectGitScope(threadRef, "unstaged"); - - panels.open(threadRef, "files"); - settle(threadRef); - clickTab(threadRef, "diff"); - - expect(diffScope(threadRef)).toBe("unstaged"); - }); - - it("still opens on Changes for generic openings", () => { - const panels = useRightPanelStore.getState(); - const diffStore = useDiffPanelStore.getState(); - - panels.open(threadRef, "diff"); - settle(threadRef); - expect(diffScope(threadRef)).toBe("branch"); - - diffStore.selectGitScope(threadRef, "unstaged"); - panels.open(threadRef, "files"); - settle(threadRef); - panels.closeSurface(threadRef, "files"); - settle(threadRef); - expect(diffScope(threadRef)).toBe("branch"); - - diffStore.selectGitScope(threadRef, "unstaged"); - panels.open(otherThreadRef, "diff"); - diffStore.selectGitScope(otherThreadRef, "unstaged"); - settle(otherThreadRef); - expect(diffScope(otherThreadRef)).toBe("branch"); - }); - - it("marks only a click that brings the Diff tab on screen", () => { - expect( - resolveExplicitDiffOpenOnActivate({ - surfaceKind: "diff", - diffOpen: false, - activeThreadRef: threadRef, - }), - ).toBe(threadRef); - expect( - resolveExplicitDiffOpenOnActivate({ - surfaceKind: "diff", - diffOpen: true, - activeThreadRef: threadRef, - }), - ).toBeNull(); - expect( - resolveExplicitDiffOpenOnActivate({ - surfaceKind: "files", - diffOpen: false, - activeThreadRef: threadRef, - }), - ).toBeNull(); - }); -}); - -describe("shouldRefocusComposerOnWindowFocus", () => { - function element( - tagName: string, - options?: { editable?: boolean; role?: string; within?: string }, - ) { - return { - tagName, - isContentEditable: options?.editable ?? false, - getAttribute: (name: string) => (name === "role" ? (options?.role ?? null) : null), - closest: (selector: string) => - options?.within !== undefined && selector.includes(options.within) ? ({} as Element) : null, - }; - } - - it("refocuses when nothing or the body holds focus", () => { - expect(shouldRefocusComposerOnWindowFocus(null)).toBe(true); - expect(shouldRefocusComposerOnWindowFocus(element("BODY"))).toBe(true); - }); - - it("refocuses away from a plain button, such as a pull request tab", () => { - expect(shouldRefocusComposerOnWindowFocus(element("BUTTON"))).toBe(true); - }); - - it("leaves other text fields alone", () => { - expect(shouldRefocusComposerOnWindowFocus(element("INPUT"))).toBe(false); - expect(shouldRefocusComposerOnWindowFocus(element("TEXTAREA"))).toBe(false); - expect(shouldRefocusComposerOnWindowFocus(element("DIV", { editable: true }))).toBe(false); - expect(shouldRefocusComposerOnWindowFocus(element("DIV", { role: "textbox" }))).toBe(false); - }); - - it.each(["IFRAME", "WEBVIEW"])("leaves a focused %s preview alone", (tagName) => { - expect(shouldRefocusComposerOnWindowFocus(element(tagName))).toBe(false); - }); - - it("leaves a focused device screen alone", () => { - expect(shouldRefocusComposerOnWindowFocus(element("DIV", { within: "application" }))).toBe( - false, - ); - }); - - it("leaves a focused terminal alone in the drawer and the right panel", () => { - expect( - shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "data-terminal-owner" })), - ).toBe(false); - }); - - it("leaves focus inside a dialog or popup alone", () => { - expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "dialog" }))).toBe(false); - expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false); - }); -}); - describe("checkout Git memory", () => { - it("answers from the last status seen for the same checkout", () => { - rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", false); - expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(false); - rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", true); - expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(true); - }); - - it("does not answer for a checkout it has not seen", () => { - expect(recallCheckoutIsRepo(environmentId, "/repo/never-opened")).toBeUndefined(); - expect(recallCheckoutIsRepo(environmentId, null)).toBeUndefined(); - }); - - it("keeps environments apart", () => { - rememberCheckoutIsRepo(environmentId, "/repo/shared-path", false); - expect( - recallCheckoutIsRepo(EnvironmentId.make("env-other"), "/repo/shared-path"), - ).toBeUndefined(); - }); - it("does not confuse an environment id containing the separator with a path", () => { rememberCheckoutIsRepo(EnvironmentId.make("env"), "a:b", false); expect(recallCheckoutIsRepo(EnvironmentId.make("env:a"), "b")).toBeUndefined(); }); }); -describe("threadShellHasStarted", () => { - it("counts a thread that has a user message but no latest turn", () => { - expect( - threadShellHasStarted({ latestRun: null, latestUserMessageAt: now, runtime: null }), - ).toBe(true); - }); - - it("counts a thread with a live runtime and nothing else", () => { - expect( - threadShellHasStarted({ - latestRun: null, - latestUserMessageAt: null, - runtime: { - providerInstanceId: ProviderInstanceId.make("codex"), - status: "starting", - providerName: "codex", - activeRunId: null, - lastError: null, - updatedAt: now, - }, - }), - ).toBe(true); - }); - - it("does not count a thread that never sent anything", () => { - expect( - threadShellHasStarted({ latestRun: null, latestUserMessageAt: null, runtime: null }), - ).toBe(false); - expect(threadShellHasStarted(null)).toBe(false); - }); -}); - -it("follows a changed server PR link without replacing an unrelated open panel", () => { - const previous = { - projectId: ProjectId.make("project-1"), - repository: "pingdotgg/t3code", - number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", - }; - const current = { - ...previous, - number: 43, - url: "https://github.com/pingdotgg/t3code/pull/43", - }; - const surface = { - id: "pull-request:previous", - kind: "pull-request", - projectId: previous.projectId, - repository: "PingDotGG/T3Code", - number: previous.number, - } satisfies RightPanelSurface; - - expect(shouldRetargetThreadPullRequestPanel(previous, current, surface)).toBe(true); - expect(shouldRetargetThreadPullRequestPanel(previous, previous, surface)).toBe(false); - expect(shouldRetargetThreadPullRequestPanel(previous, null, surface)).toBe(false); - expect(shouldRetargetThreadPullRequestPanel(previous, current, { ...surface, number: 99 })).toBe( - false, - ); - expect( - shouldRetargetThreadPullRequestPanel(previous, current, { - ...surface, - projectId: "another-project", - }), - ).toBe(false); -}); - it("treats case-distinct Forgejo instance paths as different PR links", () => { const previous = { projectId: ProjectId.make("project-1"), @@ -2175,30 +807,6 @@ describe("worktree setup visibility", () => { stages: [stage("checkout", "done"), stage("setup-script", "done"), stage("agent", "done")], }; - it("keeps setup presentation continuous until the provider handoff", () => { - const progress = ( - localPreparing: boolean, - runStatus: NonNullable["status"] | undefined, - latest: WorktreeSetupSnapshot | null, - held: WorktreeSetupSnapshot | null = null, - ) => resolveWorktreeSetupProgress({ threadId, localPreparing, runStatus, latest, held }); - - expect(progress(true, undefined, null).isPreparingWorktree).toBe(true); - expect(progress(false, "preparing", null).isPreparingWorktree).toBe(true); - expect(progress(false, "preparing", base).snapshot).toBe(base); - expect(progress(false, "starting", base).isPreparingWorktree).toBe(true); - const handedOff = { - ...base, - sequence: 2, - stages: [stage("setup-script", "running"), stage("agent", "done")], - }; - expect(progress(false, "starting", handedOff, base)).toEqual({ - snapshot: handedOff, - isPreparingWorktree: false, - }); - expect(progress(false, "running", null, handedOff).snapshot).toBe(handedOff); - }); - it("uses streamed setup progress immediately without reverting to an older held snapshot", () => { const newest = { ...settledDone, sequence: 9 }; const resolve = (latest: WorktreeSetupSnapshot | null, held: WorktreeSetupSnapshot | null) => @@ -2215,22 +823,6 @@ describe("worktree setup visibility", () => { expect(resolve(other, other)).toEqual({ snapshot: null, isPreparingWorktree: false }); }); - it.each(["failed", "cancelled"] as const)( - "does not keep %s setup in the preparing state", - (phase) => { - const snapshot = { ...base, phase }; - expect( - resolveWorktreeSetupProgress({ - threadId, - localPreparing: false, - runStatus: "failed", - latest: snapshot, - held: base, - }), - ).toEqual({ snapshot, isPreparingWorktree: false }); - }, - ); - it("reads the settled snapshot back from the thread's activities", () => { const activities = [ { kind: "setup-script.started", payload: {} }, @@ -2240,69 +832,6 @@ describe("worktree setup visibility", () => { expect(findRecordedWorktreeSetup(activities, threadId)).toEqual(settledDone); expect(findRecordedWorktreeSetup(activities, ThreadId.make("other"))).toBeNull(); }); - - it("shows a running setup and drops a clean one once the turn started", () => { - const visible = (snapshot: WorktreeSetupSnapshot | null, turnStarted: boolean) => - resolveVisibleWorktreeSetup({ - live: null, - recorded: snapshot, - turnStarted, - followUpSent: false, - }); - expect( - resolveVisibleWorktreeSetup({ - live: base, - recorded: null, - turnStarted: false, - followUpSent: false, - }), - ).toEqual(base); - expect(visible(settledDone, false)).toEqual(settledDone); - expect(visible(settledDone, true)).toBeNull(); - expect(visible(null, true)).toBeNull(); - }); - - it("keeps a failed script, a failed setup, and a cancelled setup visible", () => { - const scriptFailed = { - ...settledDone, - stages: [stage("checkout", "done"), stage("setup-script", "failed"), stage("agent", "done")], - }; - const visible = (snapshot: WorktreeSetupSnapshot, followUpSent = false) => - resolveVisibleWorktreeSetup({ - live: null, - recorded: snapshot, - turnStarted: true, - followUpSent, - }); - expect(visible(scriptFailed)).toEqual(scriptFailed); - const failed = { ...settledDone, phase: "failed" as const, error: "git exploded" }; - expect(visible(failed)).toEqual(failed); - const cancelled = { ...settledDone, phase: "cancelled" as const }; - expect(visible(cancelled)).toEqual(cancelled); - - expect(visible(scriptFailed, true)).toBeNull(); - expect(visible(failed, true)).toBeNull(); - expect(visible(cancelled, true)).toBeNull(); - expect(visible(settledDone, true)).toBeNull(); - const stillRunning = { - ...base, - stages: [stage("checkout", "done"), stage("setup-script", "running"), stage("agent", "done")], - }; - expect(visible(stillRunning, true)).toEqual(stillRunning); - }); - - it("prefers whichever snapshot is newer by sequence", () => { - const pick = (live: WorktreeSetupSnapshot | null, recorded: WorktreeSetupSnapshot | null) => - resolveVisibleWorktreeSetup({ live, recorded, turnStarted: false, followUpSent: false }); - expect(pick({ ...base, sequence: 3 }, { ...settledDone, sequence: 7 })).toEqual({ - ...settledDone, - sequence: 7, - }); - expect(pick({ ...settledDone, sequence: 9 }, { ...base, sequence: 1 })).toEqual({ - ...settledDone, - sequence: 9, - }); - }); }); describe("waitForRevertedMessage", () => { @@ -2312,8 +841,46 @@ describe("waitForRevertedMessage", () => { function projectionAtom() { const base = makeThreadProjectionFixture(); + const providerThreadId = ProviderThreadId.make("provider-thread"); const projection = { ...base, + thread: { ...base.thread, activeProviderThreadId: providerThreadId }, + providerThreads: [ + { + id: providerThreadId, + driver: "codex", + providerInstanceId: base.thread.providerInstanceId, + providerSessionId: ProviderSessionId.make("provider-session"), + appThreadId: base.thread.id, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 4, + handoffIds: [], + forkedFrom: null, + createdAt: base.updatedAt, + updatedAt: base.updatedAt, + }, + ], + runs: [1, 2, 3, 4].map((ordinal) => ({ + id: RunId.make(`run-${ordinal}`), + threadId: base.thread.id, + ordinal, + providerInstanceId: base.thread.providerInstanceId, + modelSelection: base.thread.modelSelection, + providerThreadId, + userMessageId: MessageId.make(`message-${ordinal}`), + rootNodeId: null, + activeAttemptId: null, + status: "completed", + requestedAt: base.updatedAt, + startedAt: base.updatedAt, + completedAt: base.updatedAt, + checkpointId: null, + contextHandoffId: null, + })), messages: [ { id: messageId, @@ -2338,7 +905,7 @@ describe("waitForRevertedMessage", () => { it("rejects with the projected reason when the rollback fails for good", async () => { const { state, projection } = projectionAtom(); - const waiting = waitForRevertedMessage(threadRef, messageId, 1, requestId, async () => {}); + const waiting = waitForRevertedMessage(threadRef, 1, requestId, async () => {}); await Promise.resolve(); appAtomRegistry.set(state, { data: Option.some({ @@ -2353,23 +920,184 @@ describe("waitForRevertedMessage", () => { await expect(waiting).rejects.toThrow("The provider could not roll back."); }); - it("ignores a failure recorded for an earlier rollback", async () => { + it.each([ + ["same", "streamed"], + ["replacement", "streamed"], + ["replacement", "refetched"], + ] as const)( + "waits for the retained rollback suffix when the edited message and run are paged out (%s provider, %s)", + async (providerIdentity, projectionSource) => { + vi.useFakeTimers(); + const { state, projection } = projectionAtom(); + const latestRunId = RunId.make("latest-run-9"); + const latestRun = { + ...projection.runs[3]!, + id: latestRunId, + ordinal: 9, + userMessageId: MessageId.make("latest-message-9"), + status: "cancelled" as const, + }; + const bounded = { + ...projection, + messages: [], + providerThreads: projection.providerThreads.map((thread) => ({ + ...thread, + lastRunOrdinal: 9, + })), + runs: [ + { + ...latestRun, + id: RunId.make("old-rewound-run-3"), + userMessageId: MessageId.make("old-rewound-message-3"), + ordinal: 3, + status: "rolled_back" as const, + }, + latestRun, + ], + }; + const oldProviderThread = bounded.providerThreads[0]!; + const completedProviderThread = { + ...oldProviderThread, + ...(providerIdentity === "replacement" + ? { id: ProviderThreadId.make("replacement-provider-thread"), firstRunOrdinal: null } + : {}), + lastRunOrdinal: null, + }; + appAtomRegistry.set(state, { data: Option.some(bounded) }); + let outcome = "pending"; + const waiting = waitForRevertedMessage(threadRef, 0, requestId, async () => {}).then( + () => { + outcome = "reverted"; + }, + (error: Error) => { + outcome = error.message; + }, + ); + + try { + await vi.advanceTimersByTimeAsync(0); + expect(outcome).toBe("pending"); + + appAtomRegistry.set(state, { + data: Option.some({ + ...bounded, + thread: { + ...bounded.thread, + activeProviderThreadId: + projectionSource === "refetched" + ? completedProviderThread.id + : oldProviderThread.id, + }, + providerThreads: + providerIdentity === "replacement" + ? [oldProviderThread, completedProviderThread] + : [completedProviderThread], + runs: bounded.runs.map((run) => + run.id === latestRunId ? { ...run, status: "rolled_back" as const } : run, + ), + }), + }); + await vi.advanceTimersByTimeAsync(0); + expect(outcome).toBe("reverted"); + await waiting; + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }, + ); + + it("waits for the requested target when another client finishes a shallower rewind", async () => { + vi.useFakeTimers(); + const { state, projection } = projectionAtom(); + let outcome = "pending"; + const waiting = waitForRevertedMessage(threadRef, 1, requestId, async () => {}).then( + () => { + outcome = "reverted"; + }, + (error: Error) => { + outcome = error.message; + }, + ); + + try { + await vi.advanceTimersByTimeAsync(0); + expect(outcome).toBe("pending"); + + // Our command is accepted while the earlier target-3 effect is still running. + const shallowerRewind = { + ...projection, + thread: { ...projection.thread, rollbackRequestId: requestId, rollbackFailure: null }, + providerThreads: projection.providerThreads.map((thread) => ({ + ...thread, + lastRunOrdinal: 3, + })), + runs: projection.runs.map((run) => + run.ordinal > 3 ? { ...run, status: "rolled_back" as const } : run, + ), + }; + appAtomRegistry.set(state, { data: Option.some(shallowerRewind) }); + await vi.advanceTimersByTimeAsync(0); + expect(outcome).toBe("pending"); + + appAtomRegistry.set(state, { + data: Option.some({ + ...shallowerRewind, + providerThreads: shallowerRewind.providerThreads.map((thread) => ({ + ...thread, + lastRunOrdinal: 1, + })), + runs: shallowerRewind.runs.map((run) => + run.ordinal > 1 ? { ...run, status: "rolled_back" as const } : run, + ), + }), + }); + await vi.advanceTimersByTimeAsync(0); + expect(outcome).toBe("reverted"); + await waiting; + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + + it("rejects a stale edit already rewound by another client during attachment preparation", async () => { vi.useFakeTimers(); const { state, projection } = projectionAtom(); - const waiting = waitForRevertedMessage(threadRef, messageId, 1, requestId, async () => {}, 50); - const settled = expect(waiting).rejects.toThrow("Timed out waiting for the thread to rewind."); appAtomRegistry.set(state, { data: Option.some({ ...projection, - thread: { - ...projection.thread, - rollbackFailure: { requestId: CommandId.make("rollback-0"), message: "Old failure." }, - }, + thread: { ...projection.thread, rollbackRequestId: CommandId.make("other-client-rewind") }, + messages: [], + providerThreads: projection.providerThreads.map((thread) => ({ + ...thread, + lastRunOrdinal: 1, + })), + runs: projection.runs.map((run) => + run.ordinal > 1 ? { ...run, status: "rolled_back" as const } : run, + ), }), }); - await vi.advanceTimersByTimeAsync(50); - await settled; - vi.useRealTimers(); + const revert = vi.fn(async () => {}); + let outcome = "pending"; + const waiting = waitForRevertedMessage(threadRef, 1, requestId, revert).then( + () => { + outcome = "reverted"; + }, + () => { + outcome = "rejected"; + }, + ); + + try { + await vi.advanceTimersByTimeAsync(0); + expect(outcome).toBe("rejected"); + expect(revert).not.toHaveBeenCalled(); + await waiting; + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } }); }); @@ -2395,17 +1123,6 @@ describe("mergeDirectPreviewAnnotation", () => { const image = (id: string) => ({ id, type: "image", name: `${id}.png`, mimeType: "image/png", sizeBytes: 1 }) as never; - it("appends the screenshot and claims the crop when the turn has room", () => { - const result = mergeDirectPreviewAnnotation({ - images: [], - fileCount: 0, - previewAnnotations: [], - direct: { annotation: annotation("a"), image: image("crop") }, - }); - expect(result.images.map((entry) => entry.id)).toEqual(["crop"]); - expect(result.previewAnnotations[0]?.screenshot?.dataUrl).toBe(""); - }); - it("drops the screenshot claim when the attachment cap is full", () => { const result = mergeDirectPreviewAnnotation({ images: [], @@ -2416,41 +1133,208 @@ describe("mergeDirectPreviewAnnotation", () => { expect(result.images).toEqual([]); expect(result.previewAnnotations[0]?.screenshot).toBeNull(); }); +}); - it("keeps an annotation already in the draft as it is", () => { - const existing = [annotation("a")]; - const result = mergeDirectPreviewAnnotation({ +describe("snapshotComposerSend", () => { + const expired = { + id: "ctx-expired", + threadId, + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 4, + lineEnd: 4, + text: "", + createdAt: now, + }; + const live = { ...expired, id: "ctx-live", text: "pnpm test", lineStart: 9, lineEnd: 9 }; + + it("drops expired terminal contexts from the contexts and from the prompt", () => { + const snapshot = snapshotComposerSend({ + prompt: ensureInlineContextReferences("run", [ + terminalContextReference(expired), + terminalContextReference(live), + ]), images: [], - fileCount: 0, - previewAnnotations: existing, - direct: { annotation: annotation("a"), image: null }, + files: [], + terminalContexts: [expired, live], + sendableTerminalContexts: [live], + previewAnnotations: [], + reviewComments: [], + threadContexts: [], }); - expect(result.previewAnnotations).toBe(existing); + + expect(snapshot.terminalContexts).toEqual([live]); + expect(snapshot.messageText).toBe( + ensureInlineContextReferences("run", [terminalContextReference(live)]).trim(), + ); }); }); -describe("isThreadWokeUnseen", () => { +describe("planComposerTurn", () => { const base = { - wokeAt: "2026-10-16T10:00:00.000Z", - settled: false, - lastVisitedAt: null, - latestRunCompletedAt: null, + messageText: "hello", + sendQueuesBehindSetup: false, + isServerThread: false, + activeMessageCount: 0, + sendEnvMode: "worktree" as const, + worktreePath: null, + activeThreadBranch: "main", + resumeCompactionTokens: null, + keepFullHistory: false, + phase: "ready" as const, + dispatchMode: "auto" as const, }; - it("shows a wake the user has not seen", () => { - expect(isThreadWokeUnseen({ ...base, lastVisitedAt: "2026-10-16T09:00:00.000Z" })).toBe(true); - expect(isThreadWokeUnseen(base)).toBe(true); + it("prepares a worktree from the selected branch on a new thread", () => { + expect(planComposerTurn(base)).toMatchObject({ + missingWorktreeBaseBranch: false, + baseBranchForWorktree: "main", + shouldQueueBehindActiveRun: false, + }); + }); + + it("refuses a new worktree without a base branch", () => { + expect(planComposerTurn({ ...base, activeThreadBranch: null })).toMatchObject({ + missingWorktreeBaseBranch: true, + baseBranchForWorktree: null, + }); + }); + + it("queues behind a compaction of a stale session, except for /compact itself", () => { + expect(planComposerTurn({ ...base, resumeCompactionTokens: 120_000 })).toMatchObject({ + compactBeforeSend: true, + turnDispatchMode: "queue", + shouldQueueBehindActiveRun: true, + }); + expect( + planComposerTurn({ ...base, resumeCompactionTokens: 120_000, messageText: "/COMPACT" }), + ).toMatchObject({ compactBeforeSend: false, turnDispatchMode: "auto" }); + }); + + it("queues behind a running turn only when asked to", () => { + const running = { + ...base, + isServerThread: true, + activeMessageCount: 3, + phase: "running" as const, + }; + expect(planComposerTurn({ ...running, dispatchMode: "queue" }).shouldQueueBehindActiveRun).toBe( + true, + ); + expect(planComposerTurn({ ...running, dispatchMode: "steer" })).toMatchObject({ + shouldQueueBehindActiveRun: false, + baseBranchForWorktree: null, + }); + }); +}); + +describe("withMessageContext", () => { + const context = { records: [] } as unknown as NonNullable< + Parameters[0]["context"] + >; + + it("sends the context inline when the server supports it", () => { + expect(withMessageContext({ text: "hi", context, supportsInlineMessageContext: true })).toEqual( + { text: "hi", context }, + ); + }); + + it("folds the context into the text for older servers", () => { + const result = withMessageContext({ text: "hi", context, supportsInlineMessageContext: false }); + expect(result.context).toBeUndefined(); + expect(result.text).toContain("hi"); + }); + + it("sends the text alone without context", () => { + expect( + withMessageContext({ text: "hi", context: undefined, supportsInlineMessageContext: false }), + ).toEqual({ text: "hi" }); + }); +}); + +describe("boundAttachmentIds", () => { + it("prefers the uploaded id and falls back to the local one", () => { + expect( + boundAttachmentIds([{ id: "upload-1" }, {}], [{ id: "local-1" }, { id: "local-2" }]), + ).toEqual(["upload-1", "local-2"]); }); +}); + +describe("deriveAvailableRevertTurnCounts", () => { + const providerThreadId = ProviderThreadId.make("provider-thread"); + const otherProviderThreadId = ProviderThreadId.make("other-provider-thread"); + + function projection(input: { readonly providerSessionId: ProviderSessionId | null }) { + const base = makeThreadProjectionFixture(); + const run = (ordinal: number) => ({ + id: RunId.make(`run-${ordinal}`), + ordinal, + activeAttemptId: `attempt-${ordinal}`, + }); + const turn = (ordinal: number, attempt: number, threadId: ProviderThreadId) => ({ + id: `turn-${attempt}-${ordinal}`, + runAttemptId: `attempt-${attempt}`, + ordinal, + providerThreadId: threadId, + }); + return { + ...base, + thread: { ...base.thread, activeProviderThreadId: providerThreadId }, + providerThreads: [ + { + id: providerThreadId, + providerInstanceId: base.thread.modelSelection.instanceId, + providerSessionId: input.providerSessionId, + }, + ], + runs: [run(1), run(2), run(3)], + attempts: [1, 2, 3].map((ordinal) => ({ id: `attempt-${ordinal}`, providerTurnId: null })), + // Run 2's latest turn moved to another provider thread, so its checkpoint cannot restore + // this conversation. + providerTurns: [ + turn(1, 1, providerThreadId), + turn(1, 2, providerThreadId), + turn(2, 2, otherProviderThreadId), + turn(1, 3, providerThreadId), + ], + } as unknown as ReturnType; + } - it("hides a wake seen by a later visit or a later completed run", () => { - expect(isThreadWokeUnseen({ ...base, lastVisitedAt: "2026-10-16T11:00:00.000Z" })).toBe(false); - expect(isThreadWokeUnseen({ ...base, latestRunCompletedAt: "2026-10-16T11:00:00.000Z" })).toBe( - false, + it("offers the thread start and runs whose latest turn is on the active provider thread", () => { + const available = deriveAvailableRevertTurnCounts( + projection({ providerSessionId: ProviderSessionId.make("session") }), ); + expect([...available].toSorted()).toEqual([0, 1, 3]); + }); + + it("offers nothing without a provider session to rewind", () => { + expect(deriveAvailableRevertTurnCounts(projection({ providerSessionId: null })).size).toBe(0); + expect(deriveAvailableRevertTurnCounts(null).size).toBe(0); }); +}); + +describe("deriveWorkspaceMutationId", () => { + const item = (id: string, type: string, status: string) => + ({ + item: { id: TurnItemId.make(id), type, status }, + }) as unknown as OrchestrationV2ProjectedTurnItem; - it("hides settled threads and unparseable wake times", () => { - expect(isThreadWokeUnseen({ ...base, settled: true })).toBe(false); - expect(isThreadWokeUnseen({ ...base, wokeAt: "not a date" })).toBe(false); + it("keys on the latest finished command or file change and the latest checkpoint", () => { + expect( + deriveWorkspaceMutationId( + [ + item("edit", "file_change", "completed"), + item("reply", "assistant_message", "completed"), + item("command", "command_execution", "running"), + ], + "2026-01-01T00:00:00.000Z", + ), + ).toBe(JSON.stringify(["edit", "2026-01-01T00:00:00.000Z"])); + }); + + it("is null before anything could have touched the workspace", () => { + expect(deriveWorkspaceMutationId([item("reply", "assistant_message", "completed")], null)).toBe( + null, + ); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 456048b51b6e..3bc2c1a8da31 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,6 +1,24 @@ import { normalizeSourceControlRepository } from "@t3tools/shared/sourceControl"; +import { type OrchestrationV2ProviderTurn, type RunAttemptId } from "@t3tools/contracts"; +import { + deriveLatestThreadRun, + deriveThreadRuntime, +} from "@t3tools/client-runtime/state/thread-execution"; +import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; +import { + latestUnheldRun, + usageLimitRunPresentedAsLatest, +} from "@t3tools/shared/orchestrationV2ThreadError"; import * as Option from "effect/Option"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { SetStateAction } from "react"; +import { applyClaudePromptEffortPrefix, resolvePromptInjectedEffort } from "@t3tools/shared/model"; +import { getProviderModelCapabilities } from "../providerModels"; +import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; +import type { + EnvironmentThread, + ThreadStructure, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; import { ANTIGRAVITY_DEFAULT_MODEL, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -13,7 +31,7 @@ import { ProjectId, type MessageId, type ModelSelection, - type OrchestrationV2ProjectedTurnItem, + type OrchestrationMessageContext, type PreviewAnnotationPayload, type ProviderInteractionMode, ProviderDriverKind, @@ -26,7 +44,6 @@ import { type ThreadLinkedPullRequest, type RunId, type WorktreeSetupSnapshot, - type WorktreeStartRemote, } from "@t3tools/contracts"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import * as DateTime from "effect/DateTime"; @@ -50,13 +67,32 @@ import { type Thread, type TurnDiffSummary, } from "../types"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { + type ComposerFileAttachment, + type ComposerImageAttachment, + type DraftId, + type DraftThreadState, +} from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadShells, environmentThreadDetails } from "../state/threads"; import { waitForAtomValue } from "../state/waitForAtomValue"; -import { filterTerminalContextsWithText, type TerminalContextDraft } from "../lib/terminalContext"; -import { stripInlineContextReferences } from "~/lib/composerContextReferences"; +import { + filterTerminalContextsWithText, + formatTerminalContextLabel, + type TerminalContextDraft, +} from "../lib/terminalContext"; +import { + removeInlineContextReference, + stripInlineContextReferences, +} from "~/lib/composerContextReferences"; +import { + previewAnnotationContextLabel, + reviewCommentContextLabel, + terminalContextReference, +} from "../lib/composerContextRecords"; +import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; +import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; import type { DraftThreadEnvMode } from "../composerDraftStore"; import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic"; import type { ReviewCommentContext } from "../reviewCommentContext"; @@ -70,10 +106,13 @@ import { type ProviderInstanceEntry, } from "../providerInstances"; +type ThreadProjection = EnvironmentThread["projection"]; +type ThreadStructureRow = ThreadStructure["visibleTurnItems"][number]; + export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; -export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; +const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export function agentControlledBrowserCloseConfirmation( surfaces: readonly RightPanelSurface[], @@ -183,12 +222,7 @@ export function shouldOpenProactiveTurnDiff(input: { export function resolveProactiveTurnDiffAction(input: { checkpoint: Pick | undefined; isGitRepo: boolean | undefined; - activeSurfaceKind: RightPanelSurface["kind"] | null; }): "defer" | "ignore" | "open" { - // An open diff already shows the work; reopening it would reset the chosen scope. - if (input.activeSurfaceKind === "pull-request" || input.activeSurfaceKind === "diff") { - return "ignore"; - } if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer"; if (input.isGitRepo === undefined) return "defer"; if ( @@ -230,15 +264,6 @@ export function codexArtifactTemplatePromptToAppend( export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); -export function resolveEffectiveInteractionMode(input: { - planModeEnabled: boolean; - composerInteractionMode: ProviderInteractionMode | null; - threadInteractionMode: ProviderInteractionMode | null | undefined; -}): ProviderInteractionMode { - if (!input.planModeEnabled) return "default"; - return input.composerInteractionMode ?? input.threadInteractionMode ?? "default"; -} - export function shouldDockDraftHeroForSubmission(input: { isDraftHeroState: boolean; activeThreadKey: string | null; @@ -430,17 +455,6 @@ export function resolveThreadSwitchTimeline(input: return { entries: rememberedForActive, displayThreadKey: input.activeThreadKey }; } - const lastReady = input.lastReady ?? peekHeldThreadTimeline(); - if ( - input.loading && - lastReady !== null && - lastReady.threadKey !== null && - lastReady.threadKey !== input.activeThreadKey && - lastReady.entries.length > 0 && - threadKeysShareEnvironment(lastReady.threadKey, input.activeThreadKey) - ) { - return { entries: lastReady.entries, displayThreadKey: lastReady.threadKey }; - } return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; } @@ -550,25 +564,6 @@ export function buildLocalDraftThread( }); } -export function shouldWriteThreadErrorToCurrentServerThread(input: { - serverThread: - | { - environmentId: EnvironmentId; - id: ThreadId; - } - | null - | undefined; - routeThreadRef: ScopedThreadRef; - targetThreadId: ThreadId; -}): boolean { - return Boolean( - input.serverThread && - input.targetThreadId === input.routeThreadRef.threadId && - input.serverThread.environmentId === input.routeThreadRef.environmentId && - input.serverThread.id === input.targetThreadId, - ); -} - export function resolveComposerProviderSelection(input: { entries: ReadonlyArray; candidateInstanceIds: ReadonlyArray; @@ -700,6 +695,17 @@ export function reconcileMountedTerminalThreadIds(input: { return nextThreadIds; } +// Null identifies the project checkout when a launch or session is known. +export function resolveTerminalWorktreePath(input: { + launchContext: { readonly worktreePath: string | null } | null; + summary: { readonly worktreePath: string | null } | null; + threadWorktreePath: string | null; +}): string | null { + if (input.launchContext !== null) return input.launchContext.worktreePath; + if (input.summary !== null) return input.summary.worktreePath; + return input.threadWorktreePath; +} + export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { return; @@ -804,33 +810,27 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[ export { readFileAsDataUrl } from "../lib/imageCompression"; -export function resolveSendEnvMode(input: { - requestedEnvMode: DraftThreadEnvMode; - isGitRepo: boolean; -}): DraftThreadEnvMode { - return input.isGitRepo ? input.requestedEnvMode : "local"; -} +export { resolveSendEnvMode } from "@t3tools/shared/projectSettings"; -/** Branch fields for `prepareWorktree`; servers reject a start remote on an existing branch. */ +/** Branch fields for `prepareWorktree`; servers reject `startFromOrigin` on an existing branch. */ export function resolvePrepareWorktreeBranchOptions(input: { createNewBranch: boolean; - startFromRemote: WorktreeStartRemote; -}): { createBranch: false } | { startFromRemote: WorktreeStartRemote } { - return input.createNewBranch - ? { startFromRemote: input.startFromRemote } - : { createBranch: false }; + startFromOrigin: boolean; +}): { createBranch?: false; startFromOrigin?: true } { + if (!input.createNewBranch) return { createBranch: false }; + return input.startFromOrigin ? { startFromOrigin: true } : {}; } export function resolveBackgroundDraftWorkspaceOptions(input: { envMode: DraftThreadEnvMode; branch: string | null; - startFromRemote: WorktreeStartRemote; + startFromOrigin: boolean; createNewBranch?: boolean; }): { envMode: DraftThreadEnvMode; branch: string | null; worktreePath: null; - startFromRemote: WorktreeStartRemote; + startFromOrigin: boolean; createNewBranch?: boolean; } { return { @@ -838,7 +838,7 @@ export function resolveBackgroundDraftWorkspaceOptions(input: { // The preceding send has already checked this branch out in its worktree. branch: input.envMode === "worktree" && input.createNewBranch === false ? null : input.branch, worktreePath: null, - startFromRemote: input.envMode === "worktree" ? input.startFromRemote : null, + startFromOrigin: input.envMode === "worktree" && input.startFromOrigin, ...(input.createNewBranch !== undefined ? { createNewBranch: input.createNewBranch } : {}), }; } @@ -1090,7 +1090,6 @@ export async function waitForStartedServerThread( export async function waitForRevertedMessage( threadRef: ScopedThreadRef, - messageId: MessageId, turnCount: number, requestId: CommandId, revert: () => Promise, @@ -1099,10 +1098,26 @@ export async function waitForRevertedMessage( const threadAtom = environmentThreadDetails.stateAtom(threadRef); const readProjection = () => Option.getOrNull(appAtomRegistry.get(threadAtom).data); const initial = readProjection(); - if (!initial?.messages.some((message) => message.id === messageId)) { - throw new Error("The message to rewind is no longer available."); + const providerThreadId = initial?.thread.activeProviderThreadId; + const initialProviderThread = initial?.providerThreads.find( + (providerThread) => providerThread.id === providerThreadId, + ); + const initialProviderThreadIds = new Set(initial?.providerThreads.map((thread) => thread.id)); + // Paging can omit the source run. Rollback commits the entire settled suffix atomically. + const witness = initial?.runs.reduce<(typeof initial.runs)[number] | undefined>((latest, run) => { + if ( + run.ordinal <= turnCount || + (run.status !== "completed" && + run.status !== "interrupted" && + run.status !== "failed" && + run.status !== "cancelled") + ) + return latest; + return latest === undefined || run.ordinal > latest.ordinal ? run : latest; + }, undefined); + if (witness === undefined) { + throw new Error("The thread has changed. Select a message to rewind again."); } - const messageRunId = initial.messages.find((message) => message.id === messageId)?.runId; return new Promise((resolve, reject) => { let settled = false; let accepted = false; @@ -1126,10 +1141,18 @@ export async function waitForRevertedMessage( } if ( accepted && - thread.runs.some( - (run) => - run.id === messageRunId && run.ordinal > turnCount && run.status === "rolled_back", - ) + thread.providerThreads.some( + (providerThread) => + (providerThread.id === providerThreadId || + // Claude resets to a fresh root session when rewinding to thread start. + (turnCount === 0 && + providerThread.ownerNodeId === null && + providerThread.appThreadId === initial?.thread.id && + providerThread.providerInstanceId === initialProviderThread?.providerInstanceId && + !initialProviderThreadIds.has(providerThread.id))) && + providerThread.lastRunOrdinal === (turnCount === 0 ? null : turnCount), + ) && + thread.runs.some((run) => run.id === witness.id && run.status === "rolled_back") ) finish(); }; @@ -1190,7 +1213,7 @@ export function createLocalDispatchSnapshot( * turn items avoids dropping steer rows in that gap. */ export function deriveCommittedServerUserMessageIds( - visibleTurnItems: ReadonlyArray, + visibleTurnItems: ReadonlyArray, ): ReadonlySet { return new Set( visibleTurnItems.flatMap((row) => @@ -1365,21 +1388,397 @@ export function mergeDirectPreviewAnnotation(input: { return { images, previewAnnotations }; } +export { isThreadWokeUnseen } from "@t3tools/client-runtime/state/thread-settled"; + +export function formatOutgoingPrompt(params: { + provider: ProviderDriverKind; + model: string | null; + models: ReadonlyArray; + effort: string | null; + text: string; +}): string { + const caps = getProviderModelCapabilities(params.models, params.model, params.provider); + const promptEffort = resolvePromptInjectedEffort(caps, params.effort); + return applyClaudePromptEffortPrefix(params.text, promptEffort); +} + +export function isCompactCommandMessage(message: ChatMessage): boolean { + const text = message.text.trim().toLowerCase(); + return message.role === "user" && text === "/compact" && !message.attachments?.length; +} + +// Errors surface through two maps (draft-keyed and thread-keyed) whose entries +// can race around promotion, so each write carries its time to let the latest +// one win when they collide. +export type LocalThreadErrorEntry = { + readonly message: string | null; + readonly at: number; +}; + +export function chatActionErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "An error occurred."; +} + +export function releaseChatTimelineAnchor( + current: T, +): T { + return current.messageId === null ? current : { ...current, messageId: null }; +} + +// The factories below build functions ChatView calls during render, or hands +// to ChatComposer, which can call them after an await. They live outside the +// component so each closure keeps only its inputs, not a whole render scope +// (see useStableCallback), while still acting on the render that made them. + +/** Records a thread error in local state, keyed by the route the making render saw. */ +export function makeThreadErrorSetter(input: { + readonly routeThreadRef: ScopedThreadRef; + readonly routeThreadKey: string; + readonly draftId: DraftId | null; + readonly serverThreadRef: ScopedThreadRef | null; + readonly setLocalServerErrorsByThreadKey: ( + update: SetStateAction>, + ) => void; + readonly setLocalDraftErrorsByDraftId: ( + update: SetStateAction>, + ) => void; +}) { + return (targetThreadId: ThreadId | null, error: string | null) => { + if (!targetThreadId) return; + const nextError = sanitizeThreadErrorMessage(error); + const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; + const { routeThreadRef, serverThreadRef } = input; + if ( + serverThreadRef && + targetThreadId === routeThreadRef.threadId && + serverThreadRef.environmentId === routeThreadRef.environmentId && + serverThreadRef.threadId === targetThreadId + ) { + input.setLocalServerErrorsByThreadKey((existing) => { + if ((existing[input.routeThreadKey]?.message ?? null) === nextError) { + return existing; + } + return { ...existing, [input.routeThreadKey]: nextEntry }; + }); + return; + } + const localDraftErrorKey = input.draftId ?? targetThreadId; + input.setLocalDraftErrorsByDraftId((existing) => { + if ((existing[localDraftErrorKey]?.message ?? null) === nextError) { + return existing; + } + return { ...existing, [localDraftErrorKey]: nextEntry }; + }); + }; +} + +/** Why the model picker should disable a model, or null when it can be chosen. */ +export function makeModelDisabledReason( + context: Omit< + Parameters[0], + "nextModelSelection" + > | null, +) { + return (instanceId: ProviderInstanceId, model: string): string | null => { + if (!context) return null; + const reason = getStartedThreadModelChangeBlockReason({ + ...context, + nextModelSelection: { instanceId, model }, + }); + return reason ? `${reason.description} Start a new thread to use this model.` : null; + }; +} + +/** What the composer held when the user pressed send, after a direct annotation merged in. */ +export interface ComposerSendContents { + readonly prompt: string; + readonly images: ReadonlyArray; + readonly files: ReadonlyArray; + readonly terminalContexts: ReadonlyArray; + readonly sendableTerminalContexts: ReadonlyArray; + readonly previewAnnotations: ReadonlyArray; + readonly reviewComments: ReadonlyArray; + readonly threadContexts: ReadonlyArray; +} + /** - * Whether a woken thread woke after the user last saw it. A run that completed - * after the last visit counts as seeing it, since the user watched it finish. + * Copies the composer contents a send dispatches, so a failed send can put the + * same draft back. Terminal contexts that lost their text are dropped from both + * the contexts and the prompt's inline references. */ -export function isThreadWokeUnseen(input: { - readonly wokeAt: string | null; - readonly settled: boolean; - readonly lastVisitedAt: string | null | undefined; - readonly latestRunCompletedAt: string | null; -}): boolean { - if (input.wokeAt === null || input.settled) return false; - const wokeAtMs = Date.parse(input.wokeAt); - if (Number.isNaN(wokeAtMs)) return false; - const seenAt = [input.lastVisitedAt, input.latestRunCompletedAt] - .map((value) => (value ? Date.parse(value) : NaN)) - .filter((ms) => !Number.isNaN(ms)); - return seenAt.every((ms) => ms < wokeAtMs); +export function snapshotComposerSend(contents: ComposerSendContents) { + const images = [...contents.images]; + const files = [...contents.files]; + const terminalContexts = [...contents.sendableTerminalContexts]; + return { + images, + files, + attachments: [...images, ...files], + terminalContexts, + previewAnnotations: [...contents.previewAnnotations], + reviewComments: [...contents.reviewComments], + threadContexts: [...contents.threadContexts], + messageText: contents.terminalContexts + .filter((context) => !terminalContexts.includes(context)) + .reduce( + (text, context) => + removeInlineContextReference(text, terminalContextReference(context).contextId).prompt, + contents.prompt, + ) + .trim(), + }; +} + +export type ComposerSendSnapshot = ReturnType; + +/** How a composer send dispatches: where it runs and whether it waits behind other work. */ +export function planComposerTurn(input: { + readonly messageText: string; + readonly sendQueuesBehindSetup: boolean; + readonly isServerThread: boolean; + readonly activeMessageCount: number; + readonly sendEnvMode: DraftThreadEnvMode; + readonly worktreePath: string | null; + readonly activeThreadBranch: string | null; + readonly resumeCompactionTokens: number | null; + readonly keepFullHistory: boolean; + readonly phase: SessionPhase; + readonly dispatchMode: ComposerDispatchMode; +}) { + const isFirstMessage = + !input.sendQueuesBehindSetup && (!input.isServerThread || input.activeMessageCount === 0); + const shouldCreateWorktree = + isFirstMessage && input.sendEnvMode === "worktree" && !input.worktreePath; + // A stale Claude session compacts before this message so the turn does not re-read the + // old history. The message queues behind the /compact run, since steering into it is + // rejected. + const compactBeforeSend = + input.resumeCompactionTokens !== null && + !input.keepFullHistory && + input.messageText.toLowerCase() !== "/compact"; + return { + // In worktree mode, require an explicit base branch so we don't silently + // fall back to local execution when branch selection is missing. + missingWorktreeBaseBranch: shouldCreateWorktree && !input.activeThreadBranch, + baseBranchForWorktree: shouldCreateWorktree ? input.activeThreadBranch : null, + compactBeforeSend, + turnDispatchMode: + compactBeforeSend || input.sendQueuesBehindSetup ? ("queue" as const) : input.dispatchMode, + shouldQueueBehindActiveRun: + compactBeforeSend || + input.sendQueuesBehindSetup || + (input.phase === "running" && input.dispatchMode === "queue"), + }; +} + +/** + * The attachment ids a message's context records bind to: the upload id the + * server returned, or the local id on the data-URL path. + */ +export function boundAttachmentIds( + turnAttachments: ReadonlyArray<{ readonly id?: string | undefined }>, + attachments: ReadonlyArray<{ readonly id: string }>, +): string[] { + return turnAttachments.map((attachment, index) => + "id" in attachment && attachment.id !== undefined ? attachment.id : attachments[index]!.id, + ); +} + +/** + * The message text and context a turn sends. Servers from before inline + * context drop the records and forward the links as literal text, so their + * turns carry the payload the legacy way. + */ +export function withMessageContext(input: { + readonly text: string; + readonly context: OrchestrationMessageContext | undefined; + readonly supportsInlineMessageContext: boolean; +}): { readonly text: string; readonly context?: OrchestrationMessageContext } { + const { text, context } = input; + if (context === undefined) return { text }; + if (!input.supportsInlineMessageContext) { + return { text: serializeLegacyContextMessage({ text, records: context.records }) }; + } + return { text, context }; +} + +/** The attachments the optimistic user row shows before the server echoes the message. */ +export function optimisticMessageAttachments( + attachments: ReadonlyArray, +) { + return attachments.map((attachment) => + attachment.type === "image" + ? { + type: "image" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + previewUrl: attachment.previewUrl, + ...(attachment.source ? { source: attachment.source } : {}), + } + : { + type: "file" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + downloadable: false, + ...(attachment.source ? { source: attachment.source } : {}), + }, + ); +} + +/** Fallback title sources for a message with no text of its own. */ +export function sendTitleFallbackLabels(snapshot: ComposerSendSnapshot) { + const terminalContext = snapshot.terminalContexts[0]; + const reviewComment = snapshot.reviewComments[0]; + const previewAnnotation = snapshot.previewAnnotations[0]; + return [ + terminalContext ? formatTerminalContextLabel(terminalContext) : undefined, + reviewComment ? `Review: ${reviewCommentContextLabel(reviewComment)}` : undefined, + previewAnnotation ? previewAnnotationContextLabel(previewAnnotation) : undefined, + ]; +} + +/** + * A thread shell built from its loaded detail, for direct links that hydrate the detail first. + * `updatedAt` and `lastVisitedAt` are passed live: the thread structure leaves them out. + */ +export function presentThreadShellFromProjection( + environmentId: EnvironmentId, + threadId: ThreadId, + projection: ThreadStructure, + live: { + readonly updatedAt: ThreadProjection["updatedAt"]; + readonly lastVisitedAt: ThreadProjection["thread"]["lastVisitedAt"]; + }, +) { + const { updatedAt, lastVisitedAt } = live; + const latestRun = deriveLatestThreadRun(projection); + const runtime = deriveThreadRuntime({ ...projection, updatedAt }); + const pendingRuntimeRequest = + projection.runtimeRequests.findLast((request) => request.status === "pending") ?? null; + // Direct links can hydrate detail before the thread enters the shell index. + return { + ...presentThreadShell(environmentId, { + ...projection.thread, + lastVisitedAt, + latestRunId: latestRun?.runId ?? null, + activeRunId: runtime?.activeRunId ?? null, + status: runtime?.status ?? "idle", + pendingRuntimeRequest, + latestVisibleMessage: null, + latestUserMessageAt: + projection.messages.findLast((message) => message.role === "user")?.updatedAt ?? null, + hasActionableProposedPlan: projection.plans.some( + (plan) => plan.kind === "proposed_plan" && plan.status === "active", + ), + providerInstanceHistory: [ + ...new Set( + projection.providerThreads + .filter((thread) => thread.appThreadId === threadId && thread.ownerNodeId === null) + .map((thread) => thread.providerInstanceId), + ), + ], + goal: + projection.providerThreads.find( + (thread) => thread.id === projection.thread.activeProviderThreadId, + )?.goal ?? null, + itemCount: projection.turnItems.length, + visibleItemCount: projection.visibleTurnItems.length, + updatedAt, + }), + latestRun, + runtime, + }; +} + +/** Background work the provider still runs after the latest run ended. */ +export function deriveThreadPendingBackgroundTasks(projection: ThreadStructure | null) { + if (projection === null) return []; + const sessionError = + projection.providerSessions.findLast( + (session) => session.providerInstanceId === projection.thread.providerInstanceId, + )?.lastError ?? null; + const latestRun = + usageLimitRunPresentedAsLatest(projection.runs, projection.turnItems, sessionError) ?? + latestUnheldRun(projection.runs); + return [ + ...derivePendingBackgroundWork({ + latestRun, + providerThreads: projection.providerThreads, + turnItems: projection.turnItems, + activeProviderThreadId: projection.thread.activeProviderThreadId, + runs: projection.runs, + pullRequests: projection.thread.pullRequests, + }), + ]; +} + +/** + * Run ordinals the conversation can be reverted to: those whose provider turn belongs to the + * active provider thread, plus 0 for the thread start. Empty without a live provider session + * on the thread's selected instance. + */ +export function deriveAvailableRevertTurnCounts(projection: ThreadStructure | null) { + const available = new Set(); + const providerThread = projection?.providerThreads.find( + (candidate) => candidate.id === projection.thread.activeProviderThreadId, + ); + if ( + !providerThread?.providerSessionId || + providerThread.providerInstanceId !== projection?.thread.modelSelection.instanceId + ) + return available; + available.add(0); + const attemptsById = new Map(projection.attempts.map((attempt) => [attempt.id, attempt])); + const providerTurnsById = new Map< + OrchestrationV2ProviderTurn["id"], + OrchestrationV2ProviderTurn + >(); + const latestTurnByAttempt = new Map(); + for (const turn of projection.providerTurns) { + providerTurnsById.set(turn.id, turn); + if (turn.runAttemptId === null) continue; + const latest = latestTurnByAttempt.get(turn.runAttemptId); + if (latest === undefined || turn.ordinal > latest.ordinal) { + latestTurnByAttempt.set(turn.runAttemptId, turn); + } + } + for (const run of projection.runs) { + if (run.activeAttemptId === null) continue; + const attempt = attemptsById.get(run.activeAttemptId); + const providerTurn = + latestTurnByAttempt.get(run.activeAttemptId) ?? + (attempt?.providerTurnId == null ? undefined : providerTurnsById.get(attempt.providerTurnId)); + // A ready file checkpoint alone cannot restore the provider conversation. + if (providerTurn?.providerThreadId === providerThread.id) available.add(run.ordinal); + } + return available; +} + +/** + * Changes when the agent may have changed the workspace, so file views refresh. File tools are + * explicit; completed commands are included because a shell command can mutate the workspace + * without reporting the paths it touched. + */ +export function deriveWorkspaceMutationId( + visibleTurnItems: ReadonlyArray, + latestCheckpointCompletedAt: string | null, +) { + let itemId: string | null = null; + for (let index = visibleTurnItems.length - 1; index >= 0; index -= 1) { + const item = visibleTurnItems[index]?.item; + if (!item) continue; + if (item.type !== "command_execution" && item.type !== "file_change") continue; + if (item.status === "pending" || item.status === "running" || item.status === "waiting") { + continue; + } + itemId = item.id; + break; + } + return itemId === null && latestCheckpointCompletedAt === null + ? null + : JSON.stringify([itemId, latestCheckpointCompletedAt]); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 97f656427c49..7bd9286a1b7e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,36 +1,39 @@ +import { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOverlays"; +import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer"; +import { + deriveAvailableRevertTurnCounts, + deriveThreadPendingBackgroundTasks, + deriveWorkspaceMutationId, + presentThreadShellFromProjection, +} from "./ChatView.logic"; +import { usePullRequestDraftThread } from "./chat/usePullRequestDraftThread"; +import { useComposerSelection } from "./chat/useComposerSelection"; +import { useProjectCloneBanner } from "./chat/useProjectCloneBanner"; +import { useAttachmentPreviewHandoff } from "./chat/useAttachmentPreviewHandoff"; +import { useTimelineRewind } from "./chat/useTimelineRewind"; +import { useThreadLifecycle } from "./chat/useThreadLifecycle"; +import { useRightPanelActions } from "./chat/useRightPanelActions"; +import { type TerminalLaunchContext, useChatTerminalActions } from "./chat/useChatTerminalActions"; +import { useComposerSend } from "./chat/useComposerSend"; +import { useComposerResume } from "./chat/useComposerResume"; +import { useImplementPlanInNewThread } from "./chat/useProposedPlanSend"; import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider"; import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar"; import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner"; -import { - isThreadWokeUnseen, - mergeDirectPreviewAnnotation, - resolveBackgroundDraftWorkspaceOptions, - resolvePrepareWorktreeBranchOptions, - resolveDraftHeroState, - shouldDockDraftHeroForSubmission, -} from "./ChatView.logic"; -import * as DateTime from "effect/DateTime"; -import { restorePlanFollowUpComposer } from "./ChatView.logic"; -import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; +import { resolveDraftHeroState } from "./ChatView.logic"; import { useQueuedRunEdit } from "./chat/useQueuedRunEdit"; import { isPaintOnlyThreadTimeline, peekHeldThreadTimeline, peekRememberedThreadTimeline, - rememberReadyThreadTimeline, - resolveExplicitDiffOpenOnActivate, resolveThreadSwitchTimeline, - timelineHasEphemeralPreviewUrls, recallCheckoutIsRepo, rememberCheckoutIsRepo, } from "./ChatView.logic"; -import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments"; -import { useScratchProject } from "../hooks/useScratchProject"; import { useStableCallback } from "../hooks/useStableCallback"; import { useProjectScriptEditing } from "./chat/useProjectScriptEditing"; import { RightPanelSurfaceContent } from "./chat/RightPanelSurfaceContent"; -import { useThreadTerminalActions } from "./terminal/useThreadTerminalActions"; import { usePendingThreadRequests } from "./chat/usePendingThreadRequests"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; @@ -39,59 +42,33 @@ import { latestRootProviderFailure, } from "@t3tools/shared/orchestrationV2ThreadError"; import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; -import { isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { feedbackBannerItem } from "./chat/ComposerFeedback"; import { useUsageLimitsPanel } from "./chat/useUsageLimitsPanel"; import { useWorktreeSetup } from "./chat/useWorktreeSetup"; -import { useChatShortcuts, type ChatShortcutHandler } from "./chat/useChatShortcuts"; -import { - eventPathContainsSelector, - pasteTextToFocusComposer, - shouldRedirectInputToComposer, - TYPE_TO_FOCUS_EDITABLE_SELECTOR, - TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR, -} from "./chat/composerFocusRedirect"; -import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { useChatShortcuts } from "./chat/useChatShortcuts"; +import { ChatBannerLane } from "./chat/ChatBannerLane"; import * as Schema from "effect/Schema"; import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope, AuthPreviewOperateScope, - AuthTerminalReadScope, AuthTerminalOperateScope, type AssistantCitation, type ChatFileAttachment, - CommandId, - DEFAULT_MODEL, isProviderNativeSubagentThread, type EnvironmentId, type MessageId, type ModelSelection, - type ProjectScript, type PreviewAnnotationPayload, ProviderInstanceId, type ServerProvider, type ScopedThreadRef, type ThreadId, type RunId, - type RuntimeRequestId, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode, - ProviderDriverKind, - resolveProjectProviderInstanceEnabled, + resolveIdleCompaction, RuntimeMode, - type WorktreeStartRemote, } from "@t3tools/contracts"; -import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; -import { - wasBootstrapThreadDeleted, - wasBootstrapThreadNotCreated, -} from "@t3tools/client-runtime/errors"; -import { readPastedComposerContext } from "./composerInlineTokenPaste"; -import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; -import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; -import { childThreadInputsTitle } from "@t3tools/client-runtime/state/threads"; -import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; import { deriveProviderSubagentStatus, deriveReportedModelSelection, @@ -99,9 +76,7 @@ import { deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveLatestThreadRun, - deriveThreadRuntime, - presentPendingBackgroundWork, - presentProviderGoal, + deriveThreadStructureRuntime, } from "@t3tools/client-runtime/state/thread-execution"; import { resolveWorkflowOpening, @@ -112,118 +87,58 @@ import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { HandoffRow } from "./chat/V2LifecycleRow"; import { codexFeedbackMessage, - parseCodexFeedbackCommand, + isCompleteTurnItemRow, shouldShowLoadEarlierControl, - submitCodexFeedback, type CodexFeedbackSubmission, } from "@t3tools/client-runtime/state/threads"; -import { resolveThreadLastVisitedAt } from "./Sidebar.logic"; import { parseScopedThreadKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import { - applyClaudePromptEffortPrefix, - createModelSelection, - formatModelSlugName, - resolvePromptInjectedEffort, - resolveSelectableModel, -} from "@t3tools/shared/model"; -import { - projectScriptCwd, - projectScriptTerminalInput, - resolveProjectScripts, -} from "@t3tools/shared/projectScripts"; -import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; -import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; -import { - latestUnheldRun, - usageLimitRunPresentedAsLatest, -} from "@t3tools/shared/orchestrationV2ThreadError"; +import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; +import { projectScriptCwd, resolveProjectScripts } from "@t3tools/shared/projectScripts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; -import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; -import { Debouncer } from "@tanstack/react-pacer"; import { useAtomValue } from "@effect/atom-react"; -import { Atom } from "effect/reactivity"; -import { - Fragment, - type SetStateAction, - useEffect, - useEffectEvent, - useLayoutEffect, - useRef, - useState, -} from "react"; -import { flushSync } from "react-dom"; +import { type SetStateAction, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; -import { isMacPlatform } from "../lib/utils"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { isAtomCommandInterrupted, mapAtomCommandResult, - settlePromise, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import { isElectron } from "../env"; -import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; import { useActiveThreadRef } from "../hooks/useActiveThreadRef"; -import { - type ComposerSubmissionIntent, - collapseExpandedComposerCursor, - composerRequiresModifier, - parseStandaloneComposerSlashCommand, -} from "../composer-logic"; +import { composerRequiresModifier } from "../composer-logic"; import { derivePhase, - selectHandoffImageResources, deriveActivePlanState, + selectComposerTasksPlan, deriveActiveWorkStartedAt, deriveCanInterruptRunningThread, findLatestProposedPlan, + findRevertCheckpoint, hasActionableProposedPlan, isLatestRunSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; -import { - CHAT_TIMELINE_ANCHOR_OFFSET, - timelineContentOverflowsViewport, - readTimelinePosition, - observeTimelineRun, - type TimelineRunObservation, - type TimelineScrollMode, -} from "./chat/timelineScrollAnchoring"; -import { useUiStateStore } from "../uiStateStore"; import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; -import { - buildPlanImplementationThreadTitle, - buildPlanImplementationPrompt, - resolvePlanFollowUpSubmission, -} from "../proposedPlan"; -import { - DEFAULT_INTERACTION_MODE, - MAX_TERMINALS_PER_GROUP, - type ChatMessage, - type SessionPhase, - type Thread, -} from "../types"; +import { DEFAULT_INTERACTION_MODE, type ChatMessage } from "../types"; import { useTheme } from "../hooks/useTheme"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; -import { isCommandPaletteOpen } from "../commandPaletteBus"; -import { settleAndAdvance } from "../settleAndAdvance"; import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { FileMetadataThreadProvider } from "../hooks/FileMetadataThreadProvider"; -import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, type ThreadPanelPresentation, @@ -234,31 +149,20 @@ import { selectActiveRightPanelSurface, selectThreadPanelOpen, selectThreadRightPanelState, - type RightPanelSurface, useRightPanelStore, } from "../rightPanelStore"; -import { - isPreviewSupportedInRuntime, - setActivePreviewTab, - useThreadPreviewState, -} from "../previewStateStore"; -import { BrowserSettingsReadError, openUrlInPreview } from "../browser/openFileInPreview"; -import { resolveDiscoveredServerUrl } from "../browser/browserTargetResolver"; +import { isPreviewSupportedInRuntime, useThreadPreviewState } from "../previewStateStore"; import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; -import { addBrowserSurface } from "./preview/addBrowserSurface"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { usePreviewSession } from "./preview/usePreviewSession"; -import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; import { - browserMiniPlayerSource, previewMiniPlayerSourceKey, selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; import { threadPullRequestPanelTarget } from "./pullRequest/pullRequestDetail.logic"; -import { RightPanelTabs } from "./RightPanelTabs"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; import { useDeviceState } from "~/state/device"; import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; @@ -266,26 +170,12 @@ import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { shortcutLabelForCommand } from "../keybindings"; -import { PersistentThreadTerminalDrawer } from "./PersistentThreadTerminals"; -import { - AlarmClockIcon, - CheckCircle2Icon, - MessageCircleIcon, - PaperclipIcon, - ChevronDownIcon, - DownloadIcon, - GitBranchIcon, - ShieldQuestionIcon, - TargetIcon, - WifiOffIcon, -} from "lucide-react"; -import { cn, randomUUID } from "~/lib/utils"; +import { cn } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { projectScriptIdFromCommand } from "~/projectScripts"; -import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; +import { newMessageId } from "~/lib/utils"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; -import { getProviderModelCapabilities } from "../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -301,33 +191,18 @@ import { import { useNowMinute } from "../hooks/useNowMinute"; import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; -import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; -import { resolveAppModelSelectionForInstance } from "../modelSelection"; -import { - getComposerPromptInjectionState, - getComposerProviderState, -} from "./chat/composerProviderState"; -import { confirmTerminalClose } from "../lib/terminalCloseConfirm"; import { isPreviewFocused } from "../lib/previewFocus"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; -import { findReusableProjectScriptTerminal } from "../lib/projectScriptTerminal"; -import { resolveNewDraftStartFromRemote } from "../lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; -import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; import { isSameSidebarThreadRef, useSidebarPendingFileDropStore, } from "../sidebarPendingFileDropStore"; import { - beginBackgroundDraftSubmissionByRef, - clearBackgroundDraftSubmissionByRef, - finalizePromotedDraftThreadByRef, - markPromotedDraftThreadByRef, - restoreFailedBackgroundDraftThread, composerDraftHasUserContent, type ComposerFileAttachment, type ComposerImageAttachment, @@ -335,34 +210,13 @@ import { useComposerDraftStore, DraftId, } from "../composerDraftStore"; -import { - formatTerminalContextLabel, - type TerminalContextDraft, - type TerminalContextSelection, -} from "../lib/terminalContext"; -import { - ensureInlineContextReferences, - removeInlineContextReference, - stripInlineContextReferences, -} from "../lib/composerContextReferences"; -import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; -import { - buildMessageContext, - previewAnnotationContextLabel, - previewAnnotationContextReference, - reviewCommentContextLabel, - terminalContextReference, -} from "../lib/composerContextRecords"; -import { type ReviewCommentContext } from "../reviewCommentContext"; -import { environmentCatalog } from "../connection/catalog"; -import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; -import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnectDelay"; +import { type TerminalContextDraft } from "../lib/terminalContext"; +import { collectInlineContextIds } from "../lib/composerContextReferences"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; import { useEnvironmentScope } from "~/state/session"; import { - environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, serverEnvironment, @@ -370,9 +224,6 @@ import { import { threadEnvironment } from "../state/threads"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; -import { sourceControlEnvironment } from "../state/sourceControl"; -import { useProjectClone } from "../state/projectClones"; -import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/contracts"; import { type EnvironmentPresentation, useEnvironmentProviders, @@ -383,39 +234,31 @@ import { resolveThreadDetailRef, useEnvironmentSupportsServerBrowser, useProject, - useProjects, - useThreadProjection, + useQuietThreadProjection, + useThreadUpdatedAt, + useThreadLastVisitedAt, + useQuietThreadVisibleTurnItems, useThreadStatus, useThreadHistory, useQuietThreadShell, - useThreadShell, - useChildThreadInputs, - useThreadRefs, - useThreadVisibleTurnItems, useThreadCheckpointSummaries, - waitForThreadShell, } from "../state/entities"; import { useEnvironmentShellHasSnapshot } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; -import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; -import { isTimelineScrollTarget } from "./chat/timelineScrollTarget"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialogHost, openExpandedImagePreview } from "./chat/ExpandedImageDialog"; import { BranchRestoreConfirmHost, DeviceSetupDialogHost, openBranchRestoreConfirm, - openDeviceSetupDialog, - openPullRequestThreadDialog, - openRevertConfirm, PullRequestThreadDialogHost, RevertConfirmHost, } from "./chat/ChatOverlayHosts"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; -import { MessagesTimeline, type MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; +import type { MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; +import { ThreadTimeline } from "./chat/ThreadTimeline"; import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; -import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { overlayComposerIsResting, resolveComposerTimelineInset, @@ -428,7 +271,8 @@ import { useOpenFavoriteEditorShortcut } from "./chat/OpenInPickerShortcut"; import { PanelLayoutControls, type PanelLayoutControlsProps, - RightPanelMaximizeControl, + ThreadPanelHeaderControl, + WorkspaceTitlebarControls, } from "./chat/PanelLayoutControls"; import type { ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { ThreadDetailsPanel } from "./chat/ThreadDetailsPanel"; @@ -441,15 +285,12 @@ import { } from "./BranchToolbar.logic"; import { getProviderStatusBannerKey, - ProviderStatusBanner, shouldShowProviderStatusBanner, } from "./chat/ProviderStatusBanner"; import { - dismissThreadErrorBannerForSession, getThreadErrorBannerKey, isThreadErrorBannerDismissedForSession, shouldShowThreadErrorBanner, - ThreadErrorBanner, } from "./chat/ThreadErrorBanner"; import { QueuedRunsControl, type QueuedRunsControlHandle } from "./chat/QueuedRunsControl"; import { useThreadLinkedPullRequest } from "./pullRequest/useThreadLinkedPullRequest"; @@ -458,8 +299,6 @@ import { useIncrementalTimelineEntries } from "../hooks/useIncrementalTimelineEn import { useRightPanelSurfaceClose } from "../hooks/useRightPanelSurfaceClose"; import { useAgentOpenedPreviews } from "./preview/useAgentOpenedPreviews"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; -import { ThreadStatusLine } from "./chat/ThreadStatusLine"; -import { formatRelativeTimeLabel, formatRelativeTimeUntilLabel } from "../timestampFormat"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; import { @@ -472,401 +311,66 @@ import { latestProviderTurnTokenUsage, } from "../lib/contextWindow"; import { - DRAFT_HERO_TRANSITION_ANIMATION_ID, - DRAFT_HERO_TRANSITION_EASING, MOBILE_COMPOSER_VIEW_TRANSITION_NAME, MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, - runMobileComposerTransition, } from "./chat/draftHeroTransition"; -import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; import { - MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - branchMismatchKey, - buildExpiredTerminalContextToastCopy, buildLocalDraftThread, collectUserMessageBlobPreviewUrls, - createLocalDispatchSnapshot, deriveCommittedServerUserMessageIds, - deriveComposerSendState, dismissBranchMismatchForSession, - hasEnvironmentReconnectWarningGraceElapsed, - scheduleEnvironmentReconnectWarning, - hasServerAcknowledgedLocalDispatch, - isBranchMismatchDismissedForSession, - shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, - shouldReleaseTimelineAnchorForToolActivity, shouldRenderPreviewMiniPlayer, - getStartedThreadModelChangeBlockReason, - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - LastInvokedScriptByProjectSchema, - type LocalDispatchSnapshot, - cloneComposerImageForRetry, deriveLockedProvider, - readFileAsDataUrl, resolveFileAttachmentUrl, - prepareRevertedMessageAttachments, - waitForRevertedMessage, - reconcileMountedTerminalThreadIds, resolveComposerInteractionMode, resolveComposerProviderSelection, - getAntigravitySendBlockReason, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, - revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, startNewThreadForProject, codexArtifactTemplatePromptToAppend, - waitForStartedServerThread, shouldRefocusComposerOnWindowFocus, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; -import { - awaitAttachmentUploads, - getUploadedAttachments, - readAttachmentUpload, - releaseDraftAttachments, - retryAttachmentUpload, - startAttachmentUpload, -} from "../lib/attachmentUploadQueue"; -import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; -import { RightPanelSheet } from "./RightPanelSheet"; -import { previewEnvironment } from "../state/preview"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; import { assetEnvironment } from "../state/assets"; import { readEnvironmentScope, readPreparedConnection } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; -import { Button, InlineButton } from "./ui/button"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { ServerUpdateAction } from "./ServerUpdateAction"; -import { useAutoBalanceUpdateBanner } from "./chat/useAutoBalanceUpdateBanner"; +import { observeResize } from "~/lib/observeResize"; +import { ThreadVisitWatermark } from "./chat/ThreadVisitWatermark"; +import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers"; +import { ChatRightPanel } from "./chat/ChatRightPanel"; +import { useDraftEnvironmentRouting } from "./chat/useDraftEnvironmentRouting"; +import { branchMismatchBannerItem } from "./chat/ThreadActivityBanners"; +import { useTimelineFollow } from "./chat/useTimelineFollow"; import { - ComposerServerUpdateIcon, - ComposerServerUpdateStatus, -} from "./chat/ComposerServerUpdateStatus"; + type EnvironmentUnavailableState, + useSystemComposerBanners, +} from "./chat/useSystemComposerBanners"; +import { useDraftHeroLayoutTransition } from "./chat/useDraftHeroLayoutTransition"; +import { useLocalDispatchState } from "./chat/useLocalDispatchState"; +import { draftFanoutSelectionsAtom, draftSendState } from "./chat/chatViewSend"; import { - buildVersionMismatchDismissalKey, - dismissServerUpdateFailure, - dismissVersionMismatch, - isServerUpdateFailureDismissed, - isVersionMismatchDismissed, - resolveServerConfigVersionMismatch, - resolveServerSelfUpdateCapability, - serverUpdateGuidance, - supportsDesktopAppUpdate, - supportsServerUpdateThreadContinuation, -} from "../versionSkew"; -import { useAssetUrls } from "../assets/assetUrls"; -import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; -import { recallableComposerPrompt } from "./chat/composerPromptHistory"; -import { observeResize } from "~/lib/observeResize"; + isCompactCommandMessage, + type LocalThreadErrorEntry, + makeThreadErrorSetter, +} from "./ChatView.logic"; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = []; const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; -const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({}); const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray = []; -// During an active turn the thread's updatedAt advances several times per -// second, and every server-side visit is a full command dispatch plus a -// broadcast to all shell subscribers. -const VISIT_DISPATCH_THROTTLE_MS = 10_000; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_AUTO_UPDATE_ENVIRONMENTS: ReadonlyArray = []; -function useDraftHeroLayoutTransition( - isDraftHeroState: boolean, - animationsActive: boolean, - animationDurationMs: number, -) { - const transitionGroupRef = useRef(null); - const composerAnchorRef = useRef(null); - const previousStateRef = useRef(isDraftHeroState); - const previousComposerRectRef = useRef(null); - const animationRef = useRef(null); - const captureComposerRect = useStableCallback(() => { - previousComposerRectRef.current = composerAnchorRef.current?.getBoundingClientRect() ?? null; - }); - - useLayoutEffect(() => { - const transitionGroup = transitionGroupRef.current; - const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null; - const stateChanged = previousStateRef.current !== isDraftHeroState; - const mobileComposerTransitionActive = - typeof document !== "undefined" && - document.documentElement.dataset.mobileComposerRouteTransition === "true"; - - animationRef.current?.cancel(); - animationRef.current = null; - const previousComposerRect = previousComposerRectRef.current; - if ( - stateChanged && - animationsActive && - !mobileComposerTransitionActive && - transitionGroup && - previousComposerRect && - nextComposerRect && - typeof transitionGroup.animate === "function" - ) { - const translateX = previousComposerRect.left - nextComposerRect.left; - const translateY = previousComposerRect.top - nextComposerRect.top; - if (Math.abs(translateX) >= 0.5 || Math.abs(translateY) >= 0.5) { - const animation = transitionGroup.animate( - [ - { transform: `translate3d(${translateX}px, ${translateY}px, 0)` }, - { transform: "translate3d(0, 0, 0)" }, - ], - { - duration: animationDurationMs, - easing: DRAFT_HERO_TRANSITION_EASING, - }, - ); - animation.id = DRAFT_HERO_TRANSITION_ANIMATION_ID; - animationRef.current = animation; - void animation.finished - .catch(() => undefined) - .then(() => { - if (animationRef.current === animation) animationRef.current = null; - }); - } - } - previousStateRef.current = isDraftHeroState; - previousComposerRectRef.current = nextComposerRect; - }, [animationDurationMs, animationsActive, isDraftHeroState]); - - return { transitionGroupRef, composerAnchorRef, captureComposerRect } as const; -} const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); -type EnvironmentUnavailableState = { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly connection: EnvironmentConnectionPresentation; -}; - -// Kept per route so a send in flight, and the multiple-model selections, survive -// ChatView remounting while the user navigates. -const draftFanoutSelectionsAtom = Atom.family((_routeKey: string) => - Atom.make | null>(null).pipe(Atom.keepAlive), -); -const draftSendStateByRouteKey = new Map< - string, - { - readonly sendInFlight: { current: boolean }; - readonly uncertainSubmissions: { current: Map }; - } ->(); -function draftSendState(routeKey: string) { - let state = draftSendStateByRouteKey.get(routeKey); - if (!state) { - state = { sendInFlight: { current: false }, uncertainSubmissions: { current: new Map() } }; - draftSendStateByRouteKey.set(routeKey, state); - } - return state; -} - -/** - * Starts one thread of a multiple-model send. A request that may have - * reached the server before failing is remembered by `retryKey`, so sending - * again cannot silently create a duplicate thread; its failure toast offers - * to open that thread or to allow the retry. - */ -async function startFanoutThread(input: { - readonly retryKey: string; - readonly model: string; - readonly uncertainSubmissions: Map; - readonly start: (threadId: ThreadId) => Promise>; - readonly openThread: (threadId: ThreadId) => void; -}): Promise { - const { retryKey, uncertainSubmissions } = input; - const uncertainThreadId = uncertainSubmissions.get(retryKey); - const targetThreadId = uncertainThreadId ?? newThreadId(); - let requestMayHaveStarted = false; - try { - if (uncertainThreadId) { - throw new Error( - "The previous request may have started. Open its thread to check before sending again.", - ); - } - requestMayHaveStarted = true; - const result = await input.start(targetThreadId); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - if (wasBootstrapThreadDeleted(error) || wasBootstrapThreadNotCreated(error)) { - requestMayHaveStarted = false; - } - throw error; - } - return true; - } catch (error) { - if (requestMayHaveStarted && !uncertainSubmissions.has(retryKey)) { - uncertainSubmissions.set(retryKey, targetThreadId); - } - const retainedThreadId = uncertainSubmissions.get(retryKey); - const failureToastId = toastManager.add( - stackedThreadToast({ - type: "error", - title: `Could not start ${input.model}`, - description: error instanceof Error ? error.message : "Failed to send message.", - ...(retainedThreadId - ? { - timeout: 0, - data: { - secondaryActionProps: { - children: "Allow retry", - onClick: () => { - void readLocalApi() - ?.dialogs.confirm( - "The previous request may already be running. Check its thread first. Allow another send that could create a duplicate thread?", - ) - .then( - (confirmed) => { - if ( - confirmed && - uncertainSubmissions.get(retryKey) === retainedThreadId - ) { - uncertainSubmissions.delete(retryKey); - toastManager.close(failureToastId); - } - }, - () => undefined, - ); - }, - }, - }, - actionProps: { - children: "Open thread", - onClick: () => input.openThread(retainedThreadId), - }, - } - : {}), - }), - ); - return false; - } -} - -/** - * Reverts the thread to before `message` and puts that message's text and - * attachments back in the composer draft. Returns the restored prompt, or the - * error to show; on error the draft is untouched. - */ -async function rewindToMessage(input: { - readonly message: ChatMessage; - readonly environmentId: EnvironmentId; - readonly routeThreadRef: ScopedThreadRef; - readonly threadId: ThreadId; - readonly turnCount: number; - readonly restoreFiles: boolean; - readonly draftTarget: ScopedThreadRef | DraftId; - readonly composerHasPendingAttachments: boolean; - readonly createAssetUrl: Parameters< - typeof prepareRevertedMessageAttachments - >[0]["createAssetUrl"]; - readonly revertThreadCheckpoint: (value: { - environmentId: EnvironmentId; - input: { commandId: CommandId; threadId: ThreadId; turnCount: number; restoreFiles: boolean }; - }) => Promise>; -}): Promise< - | { readonly error: string; readonly prompt?: never } - | { readonly error: null; readonly prompt: string } -> { - const { message, environmentId, draftTarget } = input; - try { - if (input.composerHasPendingAttachments) { - throw new Error("Wait for attachments to finish preparing before rewinding."); - } - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error("The environment is not connected."); - const files = await prepareRevertedMessageAttachments({ - message, - environmentId, - httpBaseUrl: connection.httpBaseUrl, - createAssetUrl: input.createAssetUrl, - }); - const store = useComposerDraftStore.getState(); - const draft = store.getComposerDraft(draftTarget); - if ( - (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + files.length > - PROVIDER_SEND_TURN_MAX_ATTACHMENTS - ) { - throw new Error("Make room for this message's attachments in the composer before rewinding."); - } - const commandId = CommandId.make(randomUUID()); - await waitForRevertedMessage( - input.routeThreadRef, - message.id, - input.turnCount, - commandId, - async () => { - const result = await input.revertThreadCheckpoint({ - environmentId, - input: { - commandId, - threadId: input.threadId, - turnCount: input.turnCount, - restoreFiles: input.restoreFiles, - }, - }); - if (result._tag === "Failure") throw squashAtomCommandFailure(result); - }, - ); - const currentPrompt = store.getComposerDraft(draftTarget)?.prompt ?? ""; - const restoredPrompt = recallableComposerPrompt(message.text); - const nextPrompt = - restoredPrompt.length === 0 - ? currentPrompt - : currentPrompt.length > 0 - ? `${currentPrompt}\n\n${restoredPrompt}` - : restoredPrompt; - store.setPrompt(draftTarget, nextPrompt); - const images: ComposerImageAttachment[] = []; - const restoredFiles: ComposerFileAttachment[] = []; - files.forEach((file, index) => { - const attachment = { - id: randomUUID(), - name: file.name, - mimeType: file.type, - sizeBytes: file.size, - file, - }; - if (message.attachments?.[index]?.type === "image") { - images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) }); - } else { - restoredFiles.push({ ...attachment, type: "file" }); - } - }); - store.addImages(draftTarget, images, { allowDuplicates: true }); - store.addFiles(draftTarget, restoredFiles, { allowDuplicates: true }); - return { error: null, prompt: nextPrompt }; - } catch (error) { - return { error: error instanceof Error ? error.message : "Failed to revert thread state." }; - } -} - -function formatOutgoingPrompt(params: { - provider: ProviderDriverKind; - model: string | null; - models: ReadonlyArray; - effort: string | null; - text: string; -}): string { - const caps = getProviderModelCapabilities(params.models, params.model, params.provider); - const promptEffort = resolvePromptInjectedEffort(caps, params.effort); - return applyClaudePromptEffortPrefix(params.text, promptEffort); -} -const SCRIPT_TERMINAL_COLS = 120; -const SCRIPT_TERMINAL_ROWS = 30; - -function isCompactCommandMessage(message: ChatMessage): boolean { - const text = message.text.trim().toLowerCase(); - return message.role === "user" && text === "/compact" && !message.attachments?.length; -} type ChatViewProps = | { @@ -888,157 +392,11 @@ type ChatViewProps = draftId: DraftId; }; -interface TerminalLaunchContext { - threadId: ThreadId; - cwd: string; - worktreePath: string | null; -} - -function useLocalDispatchState(input: { - activeThread: Thread | undefined; - activeLatestRun: Thread["latestRun"] | null; - latestUserMessageId: MessageId | null; - phase: SessionPhase; - activePendingApproval: RuntimeRequestId | null; - activePendingUserInput: RuntimeRequestId | null; - threadError: string | null | undefined; -}) { - const [localDispatch, setLocalDispatch] = useState(null); - - const resetLocalDispatch = () => { - setLocalDispatch(null); - }; - - const serverAcknowledgedLocalDispatch = hasServerAcknowledgedLocalDispatch({ - localDispatch, - phase: input.phase, - latestRun: input.activeLatestRun, - latestUserMessageId: input.latestUserMessageId, - runtime: input.activeThread?.runtime ?? null, - hasPendingApproval: input.activePendingApproval !== null, - hasPendingUserInput: input.activePendingUserInput !== null, - threadError: input.threadError, - }); - const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; - const beginLocalDispatch = (options?: { - preparingWorktree?: boolean; - submissionIntent?: ComposerSubmissionIntent; - }) => { - const preparingWorktree = Boolean(options?.preparingWorktree); - setLocalDispatch((current) => { - const active = serverAcknowledgedLocalDispatch ? null : current; - if (active) { - const submissionIntent = options?.submissionIntent ?? active.submissionIntent; - return active.preparingWorktree === preparingWorktree && - active.submissionIntent === submissionIntent - ? active - : { ...active, preparingWorktree, submissionIntent }; - } - return createLocalDispatchSnapshot(input.activeThread, { - ...options, - latestUserMessageId: input.latestUserMessageId, - }); - }); - }; - - return { - beginLocalDispatch, - resetLocalDispatch, - localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null, - isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, - isSendBusy: activeLocalDispatch !== null, - backgroundSubmissionPending: activeLocalDispatch?.submissionIntent === "background", - }; -} - -// Errors surface through two maps (draft-keyed and thread-keyed) whose entries -// can race around promotion, so each write carries its time to let the latest -// one win when they collide. -type LocalThreadErrorEntry = { - readonly message: string | null; - readonly at: number; -}; - -function chatActionErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : "An error occurred."; -} - -const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; const EMPTY_HELD_TURN_DIFF_SUMMARIES: readonly never[] = []; const noopHeldTurnDiff = (_turnId: RunId, _filePath?: string) => {}; const noopHeldRevert = (_targetTurnCount: number) => {}; const noopHeldAttachment = (_attachment: ChatFileAttachment) => {}; -function releaseChatTimelineAnchor( - current: T, -): T { - return current.messageId === null ? current : { ...current, messageId: null }; -} - -// The factories below build functions ChatView calls during render, or hands -// to ChatComposer, which can call them after an await. They live outside the -// component so each closure keeps only its inputs, not a whole render scope -// (see useStableCallback), while still acting on the render that made them. - -/** Records a thread error in local state, keyed by the route the making render saw. */ -function makeThreadErrorSetter(input: { - readonly routeThreadRef: ScopedThreadRef; - readonly routeThreadKey: string; - readonly draftId: DraftId | null; - readonly serverThreadRef: ScopedThreadRef | null; - readonly setLocalServerErrorsByThreadKey: ( - update: SetStateAction>, - ) => void; - readonly setLocalDraftErrorsByDraftId: ( - update: SetStateAction>, - ) => void; -}) { - return (targetThreadId: ThreadId | null, error: string | null) => { - if (!targetThreadId) return; - const nextError = sanitizeThreadErrorMessage(error); - const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; - const { routeThreadRef, serverThreadRef } = input; - if ( - serverThreadRef && - targetThreadId === routeThreadRef.threadId && - serverThreadRef.environmentId === routeThreadRef.environmentId && - serverThreadRef.threadId === targetThreadId - ) { - input.setLocalServerErrorsByThreadKey((existing) => { - if ((existing[input.routeThreadKey]?.message ?? null) === nextError) { - return existing; - } - return { ...existing, [input.routeThreadKey]: nextEntry }; - }); - return; - } - const localDraftErrorKey = input.draftId ?? targetThreadId; - input.setLocalDraftErrorsByDraftId((existing) => { - if ((existing[localDraftErrorKey]?.message ?? null) === nextError) { - return existing; - } - return { ...existing, [localDraftErrorKey]: nextEntry }; - }); - }; -} - -/** Why the model picker should disable a model, or null when it can be chosen. */ -function makeModelDisabledReason( - context: Omit< - Parameters[0], - "nextModelSelection" - > | null, -) { - return (instanceId: ProviderInstanceId, model: string): string | null => { - if (!context) return null; - const reason = getStartedThreadModelChangeBlockReason({ - ...context, - nextModelSelection: { instanceId, model }, - }); - return reason ? `${reason.description} Start a new thread to use this model.` : null; - }; -} - export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -1050,12 +408,8 @@ export default function ChatView(props: ChatViewProps) { } = props; const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); - const hasTerminalWriteAccess = () => - readEnvironmentScope(environmentId, AuthTerminalOperateScope); - const canReadTerminal = useEnvironmentScope(environmentId, AuthTerminalReadScope); const draftId = routeKind === "draft" ? props.draftId : null; const handleNewThread = useNewThreadHandler(); - const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); const routeThreadRef = scopeThreadRef(environmentId, threadId); const routeThreadKey = scopedThreadKey(routeThreadRef); const currentRouteThreadKeyRef = useRef(routeThreadKey); @@ -1065,13 +419,10 @@ export default function ChatView(props: ChatViewProps) { currentRouteThreadKeyRef.current = null; }; }, [routeThreadKey]); - const createThread = useOrchestrationCommand(threadEnvironment.create, { reportFailure: false }); - const deleteThread = useOrchestrationCommand(threadEnvironment.delete, { reportFailure: false }); const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); - const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, }); @@ -1081,12 +432,6 @@ export default function ChatView(props: ChatViewProps) { const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, { reportFailure: false, }); - const resumeThreadQueue = useOrchestrationCommand(threadEnvironment.resumeThreadQueue, { - reportFailure: false, - }); - const uploadThreadFeedback = useOrchestrationCommand(threadEnvironment.uploadFeedback, { - reportFailure: false, - }); const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, @@ -1098,20 +443,9 @@ export default function ChatView(props: ChatViewProps) { label: "load earlier thread history", reportFailure: false, }); - const revertThreadCheckpoint = useOrchestrationCommand(threadEnvironment.revertCheckpoint, { - reportFailure: false, - }); - const forkThreadFromRun = useAtomCommand(threadEnvironment.forkFromRun, { - reportFailure: false, - }); - const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); const { environments } = useEnvironments(); const providersByEnvironment = useEnvironmentProviders(); const primaryEnvironment = usePrimaryEnvironment(); - const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); - const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { - reportFailure: false, - }); const environmentById = new Map( environments.map((environment) => [environment.environmentId, environment]), ); @@ -1124,13 +458,24 @@ export default function ChatView(props: ChatViewProps) { ? store.getDraftSession(draftId) : null, ); - const serverThread = useQuietThreadShell(routeThreadRef); + const serverThreadShell = useQuietThreadShell(routeThreadRef); const routeThreadDetailRef = resolveThreadDetailRef(routeThreadRef, { - shellExists: serverThread !== null, + shellExists: serverThreadShell !== null, waitForShell: draftThread !== null, }); - const serverThreadProjection = useThreadProjection(routeThreadDetailRef); + const serverThreadProjection = useQuietThreadProjection(routeThreadDetailRef); const serverProjection = serverThreadProjection?.projection ?? null; + // Only the shell-less fallback needs these; with a shell, the shell carries them. + const fallbackDetailRef = serverThreadShell === null ? routeThreadDetailRef : null; + const fallbackUpdatedAt = useThreadUpdatedAt(fallbackDetailRef); + const fallbackLastVisitedAt = useThreadLastVisitedAt(fallbackDetailRef); + const serverThread = + serverThreadShell !== null || serverProjection === null || fallbackUpdatedAt === null + ? serverThreadShell + : presentThreadShellFromProjection(environmentId, threadId, serverProjection, { + updatedAt: fallbackUpdatedAt, + lastVisitedAt: fallbackLastVisitedAt, + }); const promptRef = useRef(""); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; @@ -1154,10 +499,15 @@ export default function ChatView(props: ChatViewProps) { status: threadStatus, }); const threadDetailLoading = threadSyncPhase === "loading"; + const activeProviderThread = serverProjection?.providerThreads.find( + (thread) => thread.id === serverProjection.thread.activeProviderThreadId, + ); const activeThreadLiveTokenUsage = latestProviderTurnTokenUsage( serverProjection?.providerTurns ?? [], + serverProjection, + activeProviderThread, ); - const serverVisibleTurnItems = useThreadVisibleTurnItems(routeThreadDetailRef); + const serverVisibleTurnItems = useQuietThreadVisibleTurnItems(routeThreadDetailRef); const serverThreadHistory = useThreadHistory(routeThreadDetailRef); const threadHistoryControls = (() => { if (routeThreadDetailRef === null || !shouldShowLoadEarlierControl(serverThreadHistory)) { @@ -1191,9 +541,6 @@ export default function ChatView(props: ChatViewProps) { })(); const settings = useEnvironmentSettings(environmentId); const clientSettingsHydrated = useClientSettingsHydrated(); - const setStickyComposerModelSelection = useComposerDraftStore( - (store) => store.setStickyModelSelection, - ); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); // Selects primitives: an object here is new on every router store update, which @@ -1229,40 +576,25 @@ export default function ChatView(props: ChatViewProps) { const draft = store.getComposerDraft(composerDraftTarget); return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; }); + const referencedThreadContexts = useComposerDraftStore( + useShallow((store) => { + const draft = store.getComposerDraft(composerDraftTarget); + if (!draft?.threadContexts.length) return []; + // Linked prompts can replace text without pruning stored context records. + const referenced = new Set(collectInlineContextIds(draft.prompt)); + return draft.threadContexts.filter((record) => referenced.has(record.contextId)); + }), + ); const composerHasNonPromptContent = useComposerDraftStore((store) => { const draft = store.getComposerDraft(composerDraftTarget); return draft ? composerDraftHasUserContent({ ...draft, prompt: "" }) : false; }); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); - const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); - const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); - const setComposerDraftTerminalContexts = useComposerDraftStore( - (store) => store.setTerminalContexts, - ); - const setComposerDraftPreviewAnnotations = useComposerDraftStore( - (store) => store.setPreviewAnnotations, - ); - const setComposerDraftReviewComments = useComposerDraftStore((store) => store.setReviewComments); - const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts); - const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); - const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); - const setComposerDraftInteractionMode = useComposerDraftStore( - (store) => store.setInteractionMode, - ); - const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const getDraftSessionByLogicalProjectKey = useComposerDraftStore( - (store) => store.getDraftSessionByLogicalProjectKey, - ); - const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); - const setLogicalProjectDraftThreadId = useComposerDraftStore( - (store) => store.setLogicalProjectDraftThreadId, - ); const composerImagesRef = useRef([]); const composerFilesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const branchToolbarRef = useRef(null); - const pasteAsTextShortcutUntilRef = useRef(0); const [restingComposerControlsHost, setRestingComposerControlsHost] = useState(null); const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); @@ -1281,7 +613,6 @@ export default function ChatView(props: ChatViewProps) { return inserted; }; const citeAssistantTextStable = useStableCallback(citeAssistantText); - const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); // Set by "Work locally": the draft whose restored message should be resent @@ -1297,10 +628,6 @@ export default function ChatView(props: ChatViewProps) { const feedbackUploading = feedbackSubmissions.some( (submission) => submission.status === "uploading", ); - const optimisticUserMessagesRef = useRef(optimisticUserMessages); - useLayoutEffect(() => { - optimisticUserMessagesRef.current = optimisticUserMessages; - }); const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< Record >({}); @@ -1332,9 +659,6 @@ export default function ChatView(props: ChatViewProps) { const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [terminalUiLaunchContext, setTerminalUiLaunchContext] = useState(null); - const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< - Record - >({}); // Overrides chosen before a server thread's first turn; they belong to that thread only. const [pendingServerThreadEnvModeEntry, setPendingServerThreadEnvModeEntry] = useState<{ readonly threadId: ThreadId; @@ -1345,42 +669,28 @@ export default function ChatView(props: ChatViewProps) { readonly branch: string | null; } | null>(null); const [ - pendingServerThreadStartFromRemoteByThreadId, - setPendingServerThreadStartFromRemoteByThreadId, - ] = useState>({}); + pendingServerThreadStartFromOriginByThreadId, + setPendingServerThreadStartFromOriginByThreadId, + ] = useState>({}); const [ pendingServerThreadCreateNewBranchByThreadId, setPendingServerThreadCreateNewBranchByThreadId, ] = useState>({}); - const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - {}, - LastInvokedScriptByProjectSchema, - ); const legendListRef = useRef(null); const getTimelineScrollableNode = () => legendListRef.current?.getScrollableNode() ?? null; const getTimelineScrollableNodeStable = useStableCallback(getTimelineScrollableNode); const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerTimelineInset, setComposerTimelineInset] = useState(0); const composerTimelineInsetRef = useRef(0); + const [composerReservedHeight, setComposerReservedHeight] = useState(0); + const composerReservedHeightRef = useRef(0); + const composerExpandedHeightRef = useRef(0); const composerRestingRef = useRef(false); const composerMountedRef = useRef(true); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); - const isAtEndRef = useRef(true); - const isTimelineAtLogicalEnd = () => - resolveTimelineIsAtEnd(legendListRef.current?.getState()) ?? isAtEndRef.current; - const isTimelineAtLogicalEndStable = useStableCallback(isTimelineAtLogicalEnd); const [timelineOverflows, setTimelineOverflows] = useState(false); - const attachmentPreviewHandoffByMessageIdRef = useRef>({}); - const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const fanoutSelectionsAtom = draftFanoutSelectionsAtom(routeThreadKey); - const { sendInFlight: sendInFlightRef, uncertainSubmissions: uncertainMultipleSubmissionsRef } = - draftSendState(routeThreadKey); - const [resumingThreadKeys, setResumingThreadKeys] = useState>( - () => new Set(), - ); - const isResuming = resumingThreadKeys.has(routeThreadKey); - const composerSendGenerationRef = useRef(0); + const { sendInFlight: sendInFlightRef } = draftSendState(routeThreadKey); const multipleModelSelections = useAtomValue(fanoutSelectionsAtom); const setMultipleModelSelections = ( selections: SetStateAction | null>, @@ -1390,39 +700,12 @@ export default function ChatView(props: ChatViewProps) { ); }; const setMultipleModelSelectionsStable = useStableCallback(setMultipleModelSelections); - const readMultipleModelSelections = () => appAtomRegistry.get(fanoutSelectionsAtom); - const environmentUnavailableSendToastSlotRef = useRef(0); const feedbackUploadsInFlightRef = useRef(new Set()); const terminalUiOpenByThreadRef = useRef>({}); const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); - const openTerminalThreadKeys = useTerminalUiStateStore( - useShallow((state) => - Object.entries(state.terminalUiStateByThreadKey).flatMap( - ([nextThreadKey, nextTerminalUiState]) => - nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], - ), - ), - ); - const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); - const storeEnsureTerminal = useTerminalUiStateStore((state) => state.ensureTerminal); - const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); - const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); - const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); - const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); - const serverThreadRefs = useThreadRefs(); - const serverThreadKeys = serverThreadRefs.map(scopedThreadKey); - const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftThreadKeys = Object.values(draftThreadsByThreadKey).map((draftThread) => - scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), - ); - const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); - const mountedTerminalThreadRefs = mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { - const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); - return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; - }); const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) @@ -1468,7 +751,8 @@ export default function ChatView(props: ChatViewProps) { serverProjection === null ? null : deriveLatestThreadRun(serverProjection); const serverActivityRun = serverProjection === null ? null : deriveThreadActivityRun(serverProjection); - const serverRuntime = serverProjection === null ? null : deriveThreadRuntime(serverProjection); + const serverRuntime = + serverProjection === null ? null : deriveThreadStructureRuntime(serverProjection); const runlessWorkStartedAt = serverProjection === null ? null : deriveRunlessWorkStartedAt(serverProjection); const providerSubagentStatus = @@ -1514,7 +798,7 @@ export default function ChatView(props: ChatViewProps) { thread: serverProjection.thread, messages: serverProjection.messages, }); - const workflowOpeningParentProjection = useThreadProjection( + const workflowOpeningParentProjection = useQuietThreadProjection( workflowOpeningParentId === null || parentSubagentEnvironmentId === null ? null : scopeThreadRef(parentSubagentEnvironmentId, workflowOpeningParentId), @@ -1546,6 +830,8 @@ export default function ChatView(props: ChatViewProps) { ? threadError : null; const [, setThreadErrorBannerDismissTick] = useState(0); + const threadErrorIsServerRuntimeError = + localServerError === null && visibleThreadError === serverRuntime?.lastError; const defaultRuntimeMode = resolveProjectSettings(settings, activeThread?.projectId ?? null) .settings.defaultRuntimeMode; const runtimeMode = @@ -1590,17 +876,12 @@ export default function ChatView(props: ChatViewProps) { return labels; })(); const activeThreadRef = useActiveThreadRef(activeThread); - const terminalActions = useThreadTerminalActions(activeThreadRef); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const activeEnvironmentServerBrowser = useEnvironmentSupportsServerBrowser( activeThreadRef?.environmentId ?? null, ); const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser; - const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, { - container: workspaceLayoutElement, - widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`, - }); - const activeThreadShell = useQuietThreadShell(isServerThread ? activeThreadRef : null); + const activeThreadShell = serverThread; const timelineThreadError = serverRuntime?.status === "failed" && serverRuntime.lastErrorClass === "usage_limit" && @@ -1675,13 +956,6 @@ export default function ChatView(props: ChatViewProps) { const rightPanelOpen = rightPanelState.isOpen; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); - const activeTerminalDrawerPresence = usePanelPresence( - Boolean(activeThreadKey && terminalUiState.terminalOpen), - true, - panelAnimationsActive, - activeThreadKey, - panelAnimationDurationMs, - ); const rightPanelPresenceValue = { activeSurface: activeRightPanelSurface, surfaces: rightPanelState.surfaces, @@ -1745,48 +1019,31 @@ export default function ChatView(props: ChatViewProps) { } }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef, previewSessionsReady]); - const existingOpenTerminalThreadKeys = (() => { - const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); - return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); - })(); - const reconciledMountedTerminalThreadKeys = reconcileMountedTerminalThreadIds({ - currentThreadIds: mountedTerminalThreadKeys, - openThreadIds: existingOpenTerminalThreadKeys, - activeThreadId: activeThreadKey, - activeThreadTerminalOpen: activeTerminalDrawerPresence.present, - maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - }); - if ( - reconciledMountedTerminalThreadKeys.length !== mountedTerminalThreadKeys.length || - reconciledMountedTerminalThreadKeys.some( - (threadKey, index) => threadKey !== mountedTerminalThreadKeys[index], - ) - ) { - setMountedTerminalThreadKeys(reconciledMountedTerminalThreadKeys); - } const latestRunSettled = isLatestRunSettled(activeLatestRun, activeRuntime); const activePlan = deriveActivePlanState(serverProjection, activeActivityRun?.runId); - // Tasks progress for the running turn's own plan only — deriveActivePlanState - // falls back to older runs' plans, which must not label fresh work. + const activeActivityRunSettled = isLatestRunSettled(activeActivityRun, activeRuntime); + const composerTasksPlan = selectComposerTasksPlan( + serverProjection, + activePlan, + activeActivityRun?.runId ?? null, + activeActivityRunSettled, + ); const activeComposerTasksProgress = (() => { - if ( - isLatestRunSettled(activeActivityRun, activeRuntime) || - !activePlan || - activePlan.runId !== (activeActivityRun?.runId ?? null) - ) { - return null; - } - const totalSteps = activePlan.steps.length; + if (!composerTasksPlan) return null; + const live = !activeActivityRunSettled && composerTasksPlan.runId === activeActivityRun?.runId; + const totalSteps = composerTasksPlan.steps.length; if (totalSteps === 0) return null; - const completedSteps = activePlan.steps.filter((step) => step.status === "completed").length; + const completedSteps = composerTasksPlan.steps.filter( + (step) => step.status === "completed", + ).length; const step = - activePlan.steps.find((candidate) => candidate.status === "inProgress")?.step ?? - activePlan.steps.find((candidate) => candidate.status === "pending")?.step ?? - activePlan.steps.at(-1)!.step; - return { step, completedSteps, totalSteps }; + composerTasksPlan.steps.find((candidate) => candidate.status === "inProgress")?.step ?? + composerTasksPlan.steps.find((candidate) => candidate.status === "pending")?.step ?? + composerTasksPlan.steps.at(-1)!.step; + return { step, completedSteps, totalSteps, live }; })(); const activeComposerTaskSteps = - activeComposerTasksProgress && activePlan ? activePlan.steps : null; + activeComposerTasksProgress && composerTasksPlan ? composerTasksPlan.steps : null; const activeThreadEnvironmentId = activeThread?.environmentId; const activeThreadProjectId = activeThread?.projectId; const activeProjectRef = @@ -1800,99 +1057,8 @@ export default function ChatView(props: ChatViewProps) { activeProject ?? undefined, ); const activeProjectScripts = activeProject ? resolveProjectScripts(settings, activeProject) : []; - const activeProjectClone = useProjectClone(activeProjectRef); - const cancelProjectClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, { - reportFailure: false, - }); - const retryProjectClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, { - reportFailure: false, - }); - const removeClonedProject = useRemoveClonedProject(); - const runProjectCloneAction = async ( - title: string, - action: () => Promise>, - ): Promise => { - const result = await action(); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - }; - const runProjectCloneActionStable = useStableCallback(runProjectCloneAction); - const projectCloneSendBlockReason = - activeProjectClone === null - ? null - : activeProjectClone.phase === "running" - ? "Cloning repository" - : activeProjectClone.phase === "done" - ? null - : "Repository not cloned"; - const projectCloneBannerItem = (() => { - if (!activeProjectClone || !activeProjectRef || activeProjectClone.phase === "done") { - return null; - } - const name = projectCloneDisplayName(activeProjectClone); - const { environmentId, projectId } = activeProjectRef; - if (activeProjectClone.phase === "running") { - return { - id: `project-clone:${projectId}`, - variant: "info", - priority: "activity", - icon: , - title: `Cloning ${name}`, - description: projectCloneProgressSummary(activeProjectClone), - actions: ( - - ), - }; - } - const cancelled = activeProjectClone.phase === "cancelled"; - return { - id: `project-clone:${projectId}`, - variant: cancelled ? "warning" : "error", - icon: , - title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`, - description: cancelled ? "Retry to bring in the repository." : activeProjectClone.error, - actions: ( - <> - - - - ), - }; - })() as ComposerBannerStackItem | null; + const { projectCloneBannerItem, projectCloneSendBlockReason } = + useProjectCloneBanner(activeProjectRef); const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = () => { startNewThreadForProject(activeProjectRef, handleNewThread); @@ -1946,7 +1112,6 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null); }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); - const allProjects = useProjects(); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; useEffect(() => { if (!activeThreadRef || !activeProjectRef) return; @@ -1954,35 +1119,8 @@ export default function ChatView(props: ChatViewProps) { }, [activeProjectRef, activeThreadRef]); const activeEnvironment = activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); - const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; const activeEnvironmentUnavailable = - activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; - const activeReconnectingEnvironmentId = - activeEnvironmentConnectionPhase === "connecting" || - activeEnvironmentConnectionPhase === "reconnecting" - ? (activeEnvironment?.environmentId ?? null) - : null; - const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] = - useState(null); - // Each reconnect attempt gets its own grace period, including a later one to - // the same environment. - const [graceReconnectingEnvironmentId, setGraceReconnectingEnvironmentId] = useState( - activeReconnectingEnvironmentId, - ); - if (graceReconnectingEnvironmentId !== activeReconnectingEnvironmentId) { - setGraceReconnectingEnvironmentId(activeReconnectingEnvironmentId); - setReconnectWarningGraceElapsedEnvironmentId(null); - } - const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed( - activeReconnectingEnvironmentId, - reconnectWarningGraceElapsedEnvironmentId, - ); - useEffect(() => { - if (activeReconnectingEnvironmentId === null) return; - return scheduleEnvironmentReconnectWarning(() => - setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId), - ); - }, [activeReconnectingEnvironmentId]); + activeEnvironment !== null && activeEnvironment.connection.phase !== "connected"; const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; const activeEnvironmentUnavailableState = (() => { if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { @@ -1995,54 +1133,6 @@ export default function ChatView(props: ChatViewProps) { connection: activeEnvironment.connection, }; })() as EnvironmentUnavailableState | null; - const handleReconnectActiveEnvironment = async (environmentId: EnvironmentId) => { - const result = await retryEnvironment(environmentId); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not reconnect environment", - description: error instanceof Error ? error.message : "Failed to reconnect.", - }), - ); - } - }; - const handleReconnectActiveEnvironmentStable = useStableCallback( - handleReconnectActiveEnvironment, - ); - const disconnectDelayElapsed = useEnvironmentDisconnectDelay( - activeEnvironmentUnavailable ? activeEnvironment.environmentId : null, - ); - const canDisconnectActiveEnvironment = - disconnectDelayElapsed && - activeEnvironment !== null && - activeEnvironment.entry.target._tag !== "PrimaryConnectionTarget" && - !isDesktopLocalConnectionTarget(activeEnvironment.entry.target); - const [disconnectingEnvironment, setDisconnectingEnvironment] = useState(false); - const handleDisconnectActiveEnvironment = async (environmentId: EnvironmentId) => { - setDisconnectingEnvironment(true); - const result = await setEnvironmentEnabled({ environmentId, enabled: false }); - setDisconnectingEnvironment(false); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not disconnect server", - description: error instanceof Error ? error.message : "Failed to disconnect.", - }), - ); - } - return; - } - void navigate({ to: "/", replace: true }); - }; - const handleDisconnectActiveEnvironmentStable = useStableCallback( - handleDisconnectActiveEnvironment, - ); - const { openScratchProject } = useScratchProject(); const activeProjectIsScratch = activeProject !== null && isScratchProject( @@ -2063,91 +1153,18 @@ export default function ChatView(props: ChatViewProps) { activeEnvironment: activeEnvironmentOption, canPickEnvironment: hasMultipleEnvironments, }); - const openPullRequestDialog = (reference?: string) => { - if (!canCheckoutPullRequestIntoThread) { - return; - } - if (activeThreadKey === null) return; - openPullRequestThreadDialog(activeThreadKey, reference); - }; - const openPullRequestDialogStable = useStableCallback(openPullRequestDialog); - const openOrReuseProjectDraftThread = async (input: { - branch: string; - worktreePath: string | null; - envMode: DraftThreadEnvMode; - }) => { - if (!activeProject) { - throw new Error("No active project is available for this pull request."); - } - const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); - const logicalProjectKey = deriveLogicalProjectKeyFromSettings( - activeProject, + const { handlePreparedPullRequestThreadStable, openPullRequestDialogStable } = + usePullRequestDraftThread({ + routeKind, + draftId, + settings, projectGroupingSettings, - ); - const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); - if (storedDraftSession) { - setDraftThreadContext(storedDraftSession.draftId, input); - setLogicalProjectDraftThreadId( - logicalProjectKey, - activeProjectRef, - storedDraftSession.draftId, - { - threadId: storedDraftSession.threadId, - ...input, - }, - ); - if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) { - await navigate({ - to: "/draft/$draftId", - params: buildDraftThreadRouteParams(storedDraftSession.draftId), - }); - } - return storedDraftSession.threadId; - } - - const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null; - if (!isServerThread && activeDraftSession?.logicalProjectKey === logicalProjectKey && draftId) { - setDraftThreadContext(draftId, input); - setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, { - threadId: activeDraftSession.threadId, - createdAt: activeDraftSession.createdAt, - runtimeMode: activeDraftSession.runtimeMode, - interactionMode: activeDraftSession.interactionMode, - ...input, - }); - return activeDraftSession.threadId; - } - - const nextDraftId = newDraftId(); - const nextThreadId = newThreadId(); - setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { - threadId: nextThreadId, - createdAt: new Date().toISOString(), - runtimeMode: resolveProjectSettings(settings, activeProject.id, activeProject).settings - .defaultRuntimeMode, - interactionMode: DEFAULT_INTERACTION_MODE, - ...input, - }); - await navigate({ - to: "/draft/$draftId", - params: buildDraftThreadRouteParams(nextDraftId), - }); - return nextThreadId; - }; - - const handlePreparedPullRequestThread = async (input: { - branch: string; - worktreePath: string | null; - }) => { - await openOrReuseProjectDraftThread({ - branch: input.branch, - worktreePath: input.worktreePath, - envMode: input.worktreePath ? "worktree" : "local", + activeProject, + activeThreadKey, + isServerThread, + canCheckoutPullRequestIntoThread, }); - }; - const handlePreparedPullRequestThreadStable = useStableCallback(handlePreparedPullRequestThread); - const serverConfig = activeThread ? (activeEnvironment?.serverConfig ?? null) : (primaryEnvironment?.serverConfig ?? null); @@ -2184,6 +1201,11 @@ export default function ChatView(props: ChatViewProps) { ? null : clampFileAttachmentUploadBytes(advertisedFileAttachmentBytes); const envLocked = Boolean(activeThread && (activeMessageCount > 0 || activeRuntime !== null)); + // onEnvironmentChange only retargets an unsent draft. envLocked alone does not + // rule out a started thread: one whose machine is offline never loads its + // messages, so it would otherwise be offered a machine picker that does nothing. + const canChangeEnvironment = + hasMultipleEnvironments && !envLocked && draftId !== null && !draftThread?.promotedTo; const loadBalancingSettings = useClientSettings((settings) => ({ loadBalancingEnabled: settings.loadBalancingEnabled, @@ -2206,179 +1228,22 @@ export default function ChatView(props: ChatViewProps) { return environment ? [environment] : []; }) : EMPTY_AUTO_UPDATE_ENVIRONMENTS; - const autoBalanceUpdateBanner = useAutoBalanceUpdateBanner(autoUpdateEnvironments); - const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); - const versionMismatchDismissKey = - versionMismatch && activeThread - ? buildVersionMismatchDismissalKey(activeThread.environmentId, versionMismatch) - : null; - const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState( - null, - ); - const versionMismatchDismissed = - versionMismatchDismissKey === dismissedVersionMismatchKey || - isVersionMismatchDismissed(versionMismatchDismissKey); - const showVersionMismatchBanner = - versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed; - const hasMultipleRegisteredEnvironments = environments.length > 1; - const versionMismatchServerLabel = - hasMultipleRegisteredEnvironments && activeThread - ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` - : "server"; - const serverUpdateEnvironmentId = activeThread?.environmentId ?? null; - const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); - const versionMismatchInstallation = serverConfig?.environment.capabilities.serverInstallation; - const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig); - const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig); - const serverUpdateState = useAtomValue( - serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), - ); - const [dismissedServerUpdateState, setDismissedServerUpdateState] = useState< - typeof serverUpdateState | null - >(null); - const serverUpdateFailureDismissed = - serverUpdateState === dismissedServerUpdateState || - isServerUpdateFailureDismissed(serverUpdateState); - const systemComposerBannerItems = (() => { - const items: ComposerBannerStackItem[] = []; - const updateRunning = serverUpdateState.status === "running"; - const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; - const disconnectAction = - canDisconnectActiveEnvironment && activeEnvironmentUnavailableState ? ( - - ) : undefined; - const environmentReconnecting = - unavailableConnection !== null && - (unavailableConnection.phase === "connecting" || - unavailableConnection.phase === "reconnecting"); - const suppressUnavailableBanner = - environmentReconnecting && (updateRunning || !reconnectWarningGraceElapsed); - if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { - items.push({ - id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: unavailableConnection.phase === "error" ? "error" : "warning", - icon: , - title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`, - actions: ( - <> - {!environmentReconnecting ? ( - - ) : null} - {disconnectAction} - - ), - }); - } - if ( - !automaticEnvironment && - serverUpdateEnvironmentId && - (serverUpdateState.status === "idle" - ? showVersionMismatchBanner - : !serverUpdateFailureDismissed) - ) { - const updateInProgress = serverUpdateState.status === "running"; - const updateFailed = serverUpdateState.status === "failed"; - items.push({ - id: `server-version:${serverUpdateEnvironmentId}`, - variant: updateFailed ? "error" : "default", - priority: updateInProgress ? "urgent" : "notice", - icon: , - title: - updateInProgress || updateFailed ? ( - - ) : versionMismatch ? ( - - - Server update available - - } - /> - - {versionMismatchServerLabel} {versionMismatch.serverVersion}{" "} - {versionMismatch.clientVersion} - - - ) : ( - "Server update available" - ), - description: - !updateInProgress && - !updateFailed && - versionMismatchSelfUpdate !== null && - (versionMismatchSelfUpdate !== "desktop-managed" || !versionMismatchDesktopAppUpdate) - ? serverUpdateGuidance(versionMismatchSelfUpdate) - : undefined, - actions: updateInProgress ? ( - disconnectAction - ) : !versionMismatch || - (versionMismatchSelfUpdate === "desktop-managed" && - !versionMismatchDesktopAppUpdate) ? undefined : ( - - ), - ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey) - ? {} - : { - dismissLabel: "Dismiss update notice", - onDismiss: () => { - if (updateFailed) { - dismissServerUpdateFailure(serverUpdateState); - setDismissedServerUpdateState(serverUpdateState); - } - dismissVersionMismatch(versionMismatchDismissKey); - setDismissedVersionMismatchKey(versionMismatchDismissKey); - }, - }), - }); - } - if (autoBalanceUpdateBanner) items.push(autoBalanceUpdateBanner); - return items; - })() as ComposerBannerStackItem[]; - const providerInstanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings( - deriveProviderInstanceEntries(providerStatuses), - settings, - activeProject?.id ?? null, - ), + const systemComposerBannerItems = useSystemComposerBanners({ + activeEnvironment, + activeEnvironmentUnavailable, + activeEnvironmentUnavailableState, + activeThreadEnvironmentId: activeThread?.environmentId ?? null, + serverConfig, + hasMultipleRegisteredEnvironments: environments.length > 1, + automaticEnvironment, + autoUpdateEnvironments, + }); + const providerInstanceEntries = sortProviderInstanceEntries( + applyProviderInstanceSettings( + deriveProviderInstanceEntries(providerStatuses), + settings, + activeProject?.id ?? null, + ), ); const activeThreadModelInstanceId = activeThread?.modelSelection.instanceId; const { selectedProviderEntry, requestedDriverKind } = resolveComposerProviderSelection({ @@ -2524,12 +1389,21 @@ export default function ChatView(props: ChatViewProps) { ({ item }) => item.type === "compaction" && item.runId === activeActivityRun?.runId && - item.status === "completed", + (item.status === "completed" || + item.status === "failed" || + item.status === "interrupted" || + item.status === "cancelled"), + ); + const compactionRunning = serverVisibleTurnItems.some( + ({ item }) => + item.type === "compaction" && + item.runId !== null && + item.runId === activeRuntime?.activeRunId && + item.status === "running", ); const isCompacting = (isSendBusy || phase === "connecting" || phase === "running") && - compactRequestIsActive && - !compactionSettled; + (compactionRunning || (compactRequestIsActive && !compactionSettled)); const isWorking = phase === "running" || isSendBusy || @@ -2539,35 +1413,9 @@ export default function ChatView(props: ChatViewProps) { const activeContextWindow = deriveLatestContextWindowSnapshot( serverVisibleTurnItems ?? [], activeThreadLiveTokenUsage, - serverProjection?.providerThreads.find( - (thread) => thread.id === serverProjection.thread.activeProviderThreadId, - ), + activeProviderThread, ); - const pendingBackgroundTasks = (() => { - if (serverProjection === null || serverProjection === undefined) { - return []; - } - const sessionError = - serverProjection.providerSessions.findLast( - (session) => session.providerInstanceId === serverProjection.thread.providerInstanceId, - )?.lastError ?? null; - const latestRun = - usageLimitRunPresentedAsLatest( - serverProjection.runs, - serverProjection.turnItems, - sessionError, - ) ?? latestUnheldRun(serverProjection.runs); - return [ - ...derivePendingBackgroundWork({ - latestRun, - providerThreads: serverProjection.providerThreads, - turnItems: serverProjection.turnItems, - activeProviderThreadId: serverProjection.thread.activeProviderThreadId, - runs: serverProjection.runs, - pullRequests: serverProjection.thread.pullRequests, - }), - ]; - })(); + const pendingBackgroundTasks = deriveThreadPendingBackgroundTasks(serverProjection); const activeWorkStartedAt = deriveActiveWorkStartedAt(activeActivityRun, activeRuntime, localDispatchStartedAt) ?? runlessWorkStartedAt; @@ -2575,69 +1423,6 @@ export default function ChatView(props: ChatViewProps) { const sendQueuesBehindSetup = isServerThread && (activeRunPreparing || activeActivityRun?.status === "starting"); const [setupQueueSendThreadKey, setSetupQueueSendThreadKey] = useState(null); - useEffect(() => { - attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; - }, [attachmentPreviewHandoffByMessageId]); - const clearAttachmentPreviewHandoff = ( - messageId: MessageId, - previewUrls?: ReadonlyArray, - ) => { - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - const currentPreviewUrls = - previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - setAttachmentPreviewHandoffByMessageId((existing) => { - if (!(messageId in existing)) { - return existing; - } - const next = { ...existing }; - delete next[messageId]; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - for (const previewUrl of currentPreviewUrls) { - revokeBlobPreviewUrl(previewUrl); - } - }; - const clearAttachmentPreviewHandoffStable = useStableCallback(clearAttachmentPreviewHandoff); - const clearAttachmentPreviewHandoffs = () => { - attachmentPreviewPromotionInFlightByMessageIdRef.current = {}; - for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { - for (const previewUrl of previewUrls) { - revokeBlobPreviewUrl(previewUrl); - } - } - attachmentPreviewHandoffByMessageIdRef.current = {}; - setAttachmentPreviewHandoffByMessageId({}); - }; - const clearAttachmentPreviewHandoffsStable = useStableCallback(clearAttachmentPreviewHandoffs); - useEffect(() => { - return () => { - clearAttachmentPreviewHandoffsStable(); - for (const message of optimisticUserMessagesRef.current) { - revokeUserMessagePreviewUrls(message); - } - }; - }, [clearAttachmentPreviewHandoffsStable]); - const handoffAttachmentPreviews = (messageId: MessageId, previewUrls: string[]) => { - if (previewUrls.length === 0) return; - - const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - const nextPreviewUrlSet = new Set(previewUrls); - for (const previewUrl of previousPreviewUrls) { - if (!nextPreviewUrlSet.has(previewUrl)) { - revokeBlobPreviewUrl(previewUrl); - } - } - setAttachmentPreviewHandoffByMessageId((existing) => { - const next = { - ...existing, - [messageId]: previewUrls, - }; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - }; - const handoffAttachmentPreviewsStable = useStableCallback(handoffAttachmentPreviews); const downloadFileAttachment = async (attachment: ChatFileAttachment) => { const connection = readPreparedConnection(environmentId); if (!connection) { @@ -2672,132 +1457,12 @@ export default function ChatView(props: ChatViewProps) { } }; const openFileAttachmentStable = useStableCallback(openFileAttachment); - const serverAttachmentResources = selectHandoffImageResources( - Object.keys(attachmentPreviewHandoffByMessageId).length === 0 - ? undefined - : serverVisibleTurnItems.flatMap(({ item }) => - item.type === "user_message" - ? [{ id: item.messageId, role: "user" as const, attachments: item.attachments }] - : [], - ), - attachmentPreviewHandoffByMessageId, - ); - const serverAttachmentUrls = useAssetUrls(environmentId, serverAttachmentResources); - const serverAttachmentUrlById = new Map( - serverAttachmentResources.flatMap((resource, index) => { - const url = serverAttachmentUrls[index]; - return url ? [[resource.attachmentId, url] as const] : []; - }), - ); - useEffect(() => { - if (typeof Image === "undefined" || serverVisibleTurnItems.length === 0) { - return; - } - - const cleanups: Array<() => void> = []; - const userMessagesById = new Map( - serverVisibleTurnItems.flatMap((row) => - row.item.type === "user_message" ? [[String(row.item.messageId), row.item] as const] : [], - ), - ); - - for (const [messageId, handoffPreviewUrls] of Object.entries( - attachmentPreviewHandoffByMessageId, - )) { - if (attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]) { - continue; - } - - const serverMessage = userMessagesById.get(messageId); - if (serverMessage === undefined || serverMessage.attachments.length === 0) { - continue; - } - - const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) => - attachment.type === "image" - ? [serverAttachmentUrlById.get(attachment.id)].filter( - (previewUrl): previewUrl is string => previewUrl !== undefined, - ) - : [], - ); - if ( - serverPreviewUrls.length === 0 || - serverPreviewUrls.length !== handoffPreviewUrls.length || - serverPreviewUrls.some((previewUrl) => previewUrl.startsWith("blob:")) - ) { - continue; - } - - attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId] = true; - - let cancelled = false; - const imageInstances: HTMLImageElement[] = []; - - const preloadServerPreviews = Promise.all( - serverPreviewUrls.map( - (previewUrl) => - new Promise((resolve, reject) => { - const image = new Image(); - imageInstances.push(image); - const handleLoad = () => resolve(); - const handleError = () => - reject(new Error(`Failed to load server preview for ${messageId}.`)); - image.addEventListener("load", handleLoad, { once: true }); - image.addEventListener("error", handleError, { once: true }); - image.src = previewUrl; - }), - ), - ); - - void preloadServerPreviews - .then(() => { - if (cancelled) { - return; - } - clearAttachmentPreviewHandoffStable(messageId as MessageId, handoffPreviewUrls); - }) - .catch(() => { - if (!cancelled) { - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - } - }); - - cleanups.push(() => { - cancelled = true; - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - for (const image of imageInstances) { - image.src = ""; - } - }); - } - - return () => { - for (const cleanup of cleanups) { - cleanup(); - } - }; - }, [ - attachmentPreviewHandoffByMessageId, - clearAttachmentPreviewHandoffStable, - serverAttachmentUrlById, - serverVisibleTurnItems, - ]); - const timelineAttachmentUrlById = (() => { - const urls = new Map(serverAttachmentUrlById); - for (const row of serverVisibleTurnItems) { - if (row.item.type !== "user_message") continue; - const handoffUrls = attachmentPreviewHandoffByMessageId[row.item.messageId]; - if (handoffUrls === undefined) continue; - let imageIndex = 0; - for (const attachment of row.item.attachments) { - if (attachment.type !== "image") continue; - const handoffUrl = handoffUrls[imageIndex]; - imageIndex += 1; - if (handoffUrl !== undefined) urls.set(attachment.id, handoffUrl); - } - } - return urls; - })(); + const { handoffAttachmentPreviewsStable, timelineAttachmentUrlById } = + useAttachmentPreviewHandoff({ + environmentId, + serverVisibleTurnItems, + optimisticUserMessages, + }); const anchoredTimelineMessages = feedbackSubmissions.flatMap((submission) => submission.status === "interrupted" ? [] @@ -2806,8 +1471,7 @@ export default function ChatView(props: ChatViewProps) { { ...codexFeedbackMessage(submission, "assistant"), runId: null }, ], ); - const serverTimelineEntries = useIncrementalTimelineEntries(activeThreadKey, { - visibleTurnItems: serverVisibleTurnItems, + const timelineEntriesInput = { optimisticMessages: optimisticUserMessages, anchoredMessages: anchoredTimelineMessages, attachmentUrlById: timelineAttachmentUrlById, @@ -2818,6 +1482,16 @@ export default function ChatView(props: ChatViewProps) { nodes: serverProjection.nodes, plans: serverProjection.plans, }), + }; + // The structure has no assistant or reasoning text, so those rows stay out of + // ChatView's own entries (anchoring, rewind, prompt history); ThreadTimeline + // renders every row from the live turn items. + const serverTimelineRows = serverVisibleTurnItems.filter(isCompleteTurnItemRow); + const hasStreamedTimelineRows = + isServerThread && serverTimelineRows.length !== serverVisibleTurnItems.length; + const serverTimelineEntries = useIncrementalTimelineEntries(activeThreadKey, { + ...timelineEntriesInput, + visibleTurnItems: serverTimelineRows, }); const draftTimelineEntries = optimisticUserMessages.map( (message) => @@ -2832,8 +1506,9 @@ export default function ChatView(props: ChatViewProps) { const timelineMessages = timelineEntries.flatMap((entry) => entry.kind === "message" ? [entry.message] : [], ); + const timelineEmpty = timelineEntries.length === 0 && !hasStreamedTimelineRows; const displayedTimeline = resolveThreadSwitchTimeline({ - loading: timelineEntries.length === 0 && threadSyncPhase !== null, + loading: timelineEmpty && threadSyncPhase !== null, activeThreadKey, nextEntries: timelineEntries, rememberedForActive: peekRememberedThreadTimeline(activeThreadKey), @@ -2872,7 +1547,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; const isDraftHeroState = resolveDraftHeroState({ isLocalDraftThread, - hasTimelineEntries: timelineEntries.length > 0, + hasTimelineEntries: !timelineEmpty, isWorking, draftHeroDockRequested, backgroundSubmissionPending, @@ -2888,28 +1563,16 @@ export default function ChatView(props: ChatViewProps) { panelAnimationDurationMs, ); const turnDiffSummaries = useThreadCheckpointSummaries(routeThreadDetailRef); + const availableRevertTurnCounts = deriveAvailableRevertTurnCounts(serverProjection); + // A paged history window cannot identify the first loaded run as the thread start. + const threadStartCheckpointReady = + !serverThreadHistory.hasMoreHistory && + findRevertCheckpoint(serverProjection?.checkpoints ?? [], 0) !== undefined; - /** - * File tools are explicit; completed commands are included because a shell - * command can mutate the workspace without reporting the paths it touched. - */ - const workspaceMutationId = (() => { - let itemId: string | null = null; - for (let index = serverVisibleTurnItems.length - 1; index >= 0; index -= 1) { - const item = serverVisibleTurnItems[index]?.item; - if (!item) continue; - if (item.type !== "command_execution" && item.type !== "file_change") continue; - if (item.status === "pending" || item.status === "running" || item.status === "waiting") { - continue; - } - itemId = item.id; - break; - } - const latestCheckpointCompletedAt = turnDiffSummaries.at(-1)?.completedAt ?? null; - return itemId === null && latestCheckpointCompletedAt === null - ? null - : JSON.stringify([itemId, latestCheckpointCompletedAt]); - })(); + const workspaceMutationId = deriveWorkspaceMutationId( + serverVisibleTurnItems, + turnDiffSummaries.at(-1)?.completedAt ?? null, + ); const gitCwd = activeProject ? projectScriptCwd({ @@ -2999,21 +1662,6 @@ export default function ChatView(props: ChatViewProps) { const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; - useLayoutEffect(() => { - if ( - threadDetailLoading || - timelineEntries.length === 0 || - timelineHasEphemeralPreviewUrls(timelineEntries) - ) { - return; - } - rememberReadyThreadTimeline({ - threadKey: activeThreadKey, - entries: timelineEntries, - markdownCwd: gitCwd, - workspaceRoot: activeWorkspaceRoot ?? null, - }); - }, [activeThreadKey, activeWorkspaceRoot, gitCwd, threadDetailLoading, timelineEntries]); const heldPaintContext = paintOnlyDisplayedTimeline ? peekHeldThreadTimeline() : null; @@ -3099,209 +1747,33 @@ export default function ChatView(props: ChatViewProps) { "terminal.close", terminalShortcutLabelOptions, ); - const onToggleDiff = () => { - if (!isServerThread) { - return; - } - if (!diffOpen) { - onDiffPanelOpen?.(); - } - if (activeThreadRef) { - useRightPanelStore.getState().toggle(activeThreadRef, "diff"); - } - }; - const onToggleDiffStable = useStableCallback(onToggleDiff); - const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; - const loadBalancingCandidates = needsLoadBalancing - ? logicalProjectEnvironments - .filter((candidate) => { - const environment = environmentById.get(candidate.environmentId); - return ( - environment?.connection.phase === "connected" && - (loadBalancingSettings.loadBalancingWeights[candidate.environmentId] ?? 50) > 0 && - providersByEnvironment - .get(candidate.environmentId) - ?.some( - (provider) => - (activeProviderInstanceId === null || - provider.instanceId === activeProviderInstanceId) && - provider.driver === selectedProvider && - (environment.serverConfig?.settings - ? resolveProjectProviderInstanceEnabled( - environment.serverConfig.settings, - candidate.projectId, - provider.instanceId, - ) - : provider.enabled) && - provider.installed && - provider.status !== "error" && - provider.auth.status !== "unauthenticated" && - provider.availability !== "unavailable", - ) - ); - }) - .map((candidate) => candidate.environmentId) - : []; - const loadBalancing = useLoadBalancedEnvironment( - loadBalancingCandidates, - loadBalancingSettings.loadBalancingWeights, - ); - useEffect(() => { - if (!needsLoadBalancing || loadBalancing.pending || !draftId || sendInFlightRef.current) return; - const target = logicalProjectEnvironments.find( - (environment) => environment.environmentId === loadBalancing.environmentId, - ); - if (!target?.projectId) return; - setDraftThreadContext(draftId, { - projectRef: scopeProjectRef(target.environmentId, target.projectId), - environmentSelection: "auto", - loadBalancedEnvironmentId: target.environmentId, - }); - }, [ + const { needsLoadBalancing, - loadBalancing.pending, - loadBalancing.environmentId, + loadBalancing, + onAutoEnvironmentStable, + autoEnvironmentLabel, + isEnvironmentChanging, + readPendingEnvironmentChange, + onEnvironmentChangeStable, + } = useDraftEnvironmentRouting({ draftId, + draftThread, + envLocked, + automaticEnvironment, logicalProjectEnvironments, + environmentById, + providersByEnvironment, + loadBalancingSettings, + referencedThreadContexts, + activeProviderInstanceId, + selectedProvider, + composerHasAttachments, + activeProjectKey, + activeProjectIsScratch, + projectGroupingSettings, sendInFlightRef, - setDraftThreadContext, - ]); - const onAutoEnvironment = () => { - if (envLocked || !draftId) return; - if (composerHasAttachments) { - toastManager.add({ - type: "warning", - id: "load-balancing-attachments", - title: "Keep attachments on this machine", - description: - "Remove attachments before choosing automatic routing, then attach them on the selected machine.", - }); - return; - } - loadBalancing.refresh( - logicalProjectEnvironments.map((environment) => environment.environmentId), - ); - setDraftThreadContext(draftId, { - environmentSelection: "auto", - loadBalancedEnvironmentId: null, - branch: null, - worktreePath: null, - }); - }; - const onAutoEnvironmentStable = useStableCallback(onAutoEnvironment); - const autoEnvironmentLabel = automaticEnvironment - ? draftThread?.loadBalancedEnvironmentId - ? "Auto balance" - : loadBalancing.pending - ? "Checking machines…" - : loadBalancing.failed - ? "Auto balance unavailable" - : "Auto balance" - : undefined; - - // A change in flight belongs to the draft and project it started on. - const environmentChangeScope = `${draftId ?? ""}:${activeProjectKey ?? ""}`; - const environmentChangeRef = useRef<{ - readonly environmentId: EnvironmentId; - readonly scope: string; - } | null>(null); - const environmentChangeScopeRef = useRef(""); - useLayoutEffect(() => { - environmentChangeScopeRef.current = environmentChangeScope; }); - // Leaving the scope, or unmounting, abandons a change still in flight. - useLayoutEffect( - () => () => { - if (environmentChangeRef.current?.scope === environmentChangeScope) { - environmentChangeRef.current = null; - } - }, - [environmentChangeScope], - ); - const readPendingEnvironmentChange = () => { - const pending = environmentChangeRef.current; - return pending !== null && pending.scope === environmentChangeScopeRef.current ? pending : null; - }; - // The busy state belongs to the request that set it, so an older request settling late - // (abandoned, or superseded by a newer switch) can never clear a newer one's. - const [busyEnvironmentChange, setBusyEnvironmentChange] = useState<{ - readonly environmentId: EnvironmentId; - readonly scope: string; - } | null>(null); - const isEnvironmentChanging = busyEnvironmentChange?.scope === environmentChangeScope; - - const onEnvironmentChange = (nextEnvironmentId: EnvironmentId) => { - if (envLocked || !draftId || sendInFlightRef.current) return; - const originalDraft = getDraftSession(draftId); - if (!originalDraft || originalDraft.promotedTo) return; - const target = logicalProjectEnvironments.find( - (env) => env.environmentId === nextEnvironmentId, - ); - if (!target) return; - const request = { environmentId: target.environmentId, scope: environmentChangeScope }; - environmentChangeRef.current = request; - setBusyEnvironmentChange(null); - const retarget = (project: (typeof allProjects)[number]) => { - const currentDraft = getDraftSession(draftId); - if ( - environmentChangeRef.current !== request || - environmentChangeScopeRef.current !== request.scope || - sendInFlightRef.current || - !currentDraft || - currentDraft.promotedTo || - currentDraft.environmentId !== originalDraft.environmentId || - currentDraft.projectId !== originalDraft.projectId - ) - return; - const projectRef = scopeProjectRef(target.environmentId, project.id); - if (activeProjectIsScratch) { - setLogicalProjectDraftThreadId( - deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), - projectRef, - draftId, - { environmentSelection: "manual", loadBalancedEnvironmentId: null }, - ); - } else { - setDraftThreadContext(draftId, { - projectRef, - environmentSelection: "manual", - loadBalancedEnvironmentId: null, - }); - } - }; - const finish = () => { - if (environmentChangeRef.current === request) environmentChangeRef.current = null; - setBusyEnvironmentChange((busy) => (busy === request ? null : busy)); - }; - if (target.projectId !== null) { - const project = allProjects.find( - (project) => - project.environmentId === target.environmentId && project.id === target.projectId, - ); - if (project) retarget(project); - finish(); - return; - } - setBusyEnvironmentChange(request); - void openScratchProject(target.environmentId, "Could not switch machine") - .then((project) => { - if (project) retarget(project); - }) - .finally(finish); - }; - const onEnvironmentChangeStable = useStableCallback(onEnvironmentChange); - - const activeTerminalGroup = - terminalUiState.terminalGroups.find( - (group) => group.id === terminalUiState.activeTerminalGroupId, - ) ?? - terminalUiState.terminalGroups.find((group) => - group.terminalIds.includes(terminalUiState.activeTerminalId), - ) ?? - null; - const hasReachedSplitLimit = - (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; const focusComposer = () => { composerRef.current?.focusAtEnd(); @@ -3373,199 +1845,38 @@ export default function ChatView(props: ChatViewProps) { }; const sendAppMessageStable = useStableCallback(sendAppMessage); const queuedRunsControlRef = useRef(null); - const addTerminalContextToDraft = (selection: TerminalContextSelection) => { - composerRef.current?.addTerminalContext(selection); - }; - const addTerminalContextToDraftStable = useStableCallback(addTerminalContextToDraft); - const setTerminalOpen = (open: boolean) => { - if (!activeThreadRef) return; - storeSetTerminalOpen(activeThreadRef, open); - }; - const setTerminalOpenStable = useStableCallback(setTerminalOpen); - const toggleTerminalVisibility = () => { - if (!activeThreadRef) return; - const nextOpen = !terminalUiState.terminalOpen; - if ( - nextOpen && - !readEnvironmentScope(environmentId, AuthTerminalReadScope) && - !hasTerminalWriteAccess() - ) - return; - if (nextOpen && hasTerminalWriteAccess() && terminalUiState.terminalIds.length === 0) { - // Server-created setup terminals exist before this client's drawer mounts. - // Reuse them before allocating a shell from the still-empty UI state. - const existingTerminalId = - runningTerminalIds.find((id) => !panelTerminalIds.has(id)) ?? - activeServerOrderedTerminalIds.find((id) => !panelTerminalIds.has(id)); - if (existingTerminalId !== undefined) { - storeEnsureTerminal(activeThreadRef, existingTerminalId, { open: true, active: true }); - return; - } - void terminalActions.launchTerminal({ - place: (terminalId) => storeEnsureTerminal(activeThreadRef, terminalId, { open: true }), - })?.result; - return; - } - setTerminalOpen(nextOpen); - }; - const toggleTerminalVisibilityStable = useStableCallback(toggleTerminalVisibility); - const splitTerminal = (direction: "horizontal" | "vertical" = "horizontal") => { - if (!activeThreadRef || hasReachedSplitLimit) return; - const launched = terminalActions.launchTerminal({ - place: (terminalId) => - direction === "vertical" - ? storeSplitTerminalVertical(activeThreadRef, terminalId) - : storeSplitTerminal(activeThreadRef, terminalId), - }); - if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); - void launched.result; - }; - const splitTerminalStable = useStableCallback(splitTerminal); - const createNewTerminal = () => { - if (!activeThreadRef) return; - const launched = terminalActions.launchTerminal({ - place: (terminalId) => storeNewTerminal(activeThreadRef, terminalId), - }); - if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); - void launched.result; - }; - const createNewTerminalStable = useStableCallback(createNewTerminal); - const closeTerminal = (terminalId: string) => { - if (!hasTerminalWriteAccess() || !activeThreadRef) return; - terminalActions.closeTerminalSession(terminalId); - storeCloseTerminal(activeThreadRef, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }; - const runProjectScript = async ( - script: ProjectScript, - options?: { - cwd?: string; - env?: Record; - worktreePath?: string | null; - preferNewTerminal?: boolean; - rememberAsLastInvoked?: boolean; - shellHistory?: "preserve" | "suppress"; - }, - ) => { - if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return; - if (options?.rememberAsLastInvoked !== false) { - setLastInvokedScriptByProjectId((current) => { - if (current[activeProject.id] === script.id) return current; - return { ...current, [activeProject.id]: script.id }; - }); - } - const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot; - const reusableTerminalId = - options?.preferNewTerminal || - !canReuseTerminal || - !readEnvironmentScope(environmentId, AuthTerminalReadScope) - ? null - : findReusableProjectScriptTerminal({ - // Server sessions cover drawer terminals this client has not opened yet, such as a - // worktree setup shell; the helper drops the panel-owned ones. - drawerTerminalIds: [ - ...new Set([...terminalUiState.terminalIds, ...activeServerOrderedTerminalIds]), - ], - activeTerminalId: terminalUiState.activeTerminalId, - panelTerminalIds, - runningTerminalIds, - }); - const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; - - setTerminalUiLaunchContext({ - threadId: activeThreadId, - cwd: targetCwd, - worktreePath: targetWorktreePath, - }); - if (!activeThreadRef) { - return; - } - setTerminalFocusRequestId((value) => value + 1); - - const launched = terminalActions.launchTerminal({ - // The drawer opens with the target already placed: opening it empty would claim - // DEFAULT_THREAD_TERMINAL_ID, which a right-panel terminal may already own. - place: (terminalId) => - reusableTerminalId === null - ? storeNewTerminal(activeThreadRef, terminalId) - : storeEnsureTerminal(activeThreadRef, terminalId, { open: true }), - ...(reusableTerminalId === null - ? { size: { cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS } } - : { terminalId: reusableTerminalId }), - location: { cwd: targetCwd, worktreePath: targetWorktreePath }, - ...(options?.env ? { extraEnv: options.env } : {}), - }); - if (!launched) return; - const targetTerminalId = launched.terminalId; - const openResult = await launched.result; - if (openResult._tag === "Failure") { - if (!isAtomCommandInterrupted(openResult)) { - const error = squashAtomCommandFailure(openResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); - } - return; - } - - const writeResult = await terminalActions.writeToTerminal( - targetTerminalId, - options?.shellHistory === "preserve" - ? `${script.command}\r` - : projectScriptTerminalInput(script.command), - ); - if (!writeResult) return; - if (writeResult._tag === "Failure") { - if (!isAtomCommandInterrupted(writeResult)) { - const error = squashAtomCommandFailure(writeResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); - } - return; - } - if (script.autoOpenPreview && script.previewUrl && isPreviewSupportedInRuntime()) { - const previewResult = await openUrlInPreview({ - threadRef: activeThreadRef, - url: resolveDiscoveredServerUrl(activeThreadRef.environmentId, script.previewUrl), - openPreview, - }); - if (previewResult._tag === "Failure" && !isAtomCommandInterrupted(previewResult)) { - const error = squashAtomCommandFailure(previewResult); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not open preview", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - } - } - }; - const runProjectScriptStable = useStableCallback(runProjectScript); - - const runProjectScriptRef = useRef(runProjectScriptStable); - useLayoutEffect(() => { - runProjectScriptRef.current = runProjectScriptStable; - }, [runProjectScriptStable]); - const runShellCommand = (command: string) => { - void runProjectScriptRef.current( - { - id: "chat-code-block", - name: "Chat code block", - command, - icon: "play", - runOnWorktreeCreate: false, - }, - { rememberAsLastInvoked: false, shellHistory: "preserve" }, - ); - }; - const runShellCommandStable = useStableCallback(runShellCommand); + const { + activatePanelTerminalStable, + addTerminalContextToDraftStable, + addTerminalSurfaceStable, + closePanelTerminalStable, + lastInvokedScriptByProjectId, + runProjectScriptStable, + runShellCommandStable, + splitPanelTerminalStable, + splitPanelTerminalVerticalStable, + toggleTerminalVisibilityStable, + shortcuts: terminalShortcuts, + } = useChatTerminalActions({ + environmentId, + composerRef, + terminalUiState, + activeThread, + activeThreadId, + activeThreadRef, + activeProject, + activeRightPanelSurface, + runningTerminalIds, + activeServerOrderedTerminalIds, + activeTerminalLabelsById, + panelTerminalIds, + canReuseTerminal, + gitCwd, + setTerminalFocusRequestId, + setTerminalUiLaunchContext, + setThreadError, + }); const { saveProjectScript: saveProjectScriptStable, updateProjectScript: updateProjectScriptStable, @@ -3576,80 +1887,10 @@ export default function ChatView(props: ChatViewProps) { scripts: activeProjectScripts, settings, }); - - const handleRuntimeModeChange = (mode: RuntimeMode) => { - if (mode === runtimeMode) return; - setComposerDraftRuntimeMode(composerDraftTarget, mode); - if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); - } - scheduleComposerFocus(); - }; - const handleRuntimeModeChangeStable = useStableCallback(handleRuntimeModeChange); - - const handleInteractionModeChange = (mode: ProviderInteractionMode) => { - if (mode === "plan" && !interactionModeEnabled) return; - if (mode === interactionMode) return; - setComposerDraftInteractionMode(composerDraftTarget, mode); - if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { interactionMode: mode }); - } - scheduleComposerFocus(); - }; - const handleInteractionModeChangeStable = useStableCallback(handleInteractionModeChange); - const toggleInteractionMode = () => { - if (!interactionModeEnabled) return; - handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); - }; - const toggleInteractionModeStable = useStableCallback(toggleInteractionMode); - const openProviderSetup = (instanceId: ProviderInstanceId) => { - void navigate({ - to: "/settings/providers", - search: { environmentId, instanceId }, - }); - }; - const openProviderSetupStable = useStableCallback(openProviderSetup); const canOperatePreview = useEnvironmentScope( activeThreadRef?.environmentId ?? null, AuthPreviewOperateScope, ); - const createBrowserSurface = (profileId?: string) => { - if (!activeThreadRef || !canOperatePreview) return; - void addBrowserSurface({ - threadRef: activeThreadRef, - openPreview, - ...(profileId === undefined ? {} : { profileId }), - }).then((result) => { - if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; - const error = squashAtomCommandFailure(result); - if (error instanceof BrowserSettingsReadError) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open browser", - description: error.message, - }), - ); - } - }); - }; - const createBrowserSurfaceStable = useStableCallback(createBrowserSurface); - const addDiffSurface = () => { - if (!activeThreadRef || !isServerThread || !isGitRepo) return; - useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); - useRightPanelStore.getState().open(activeThreadRef, "diff"); - onDiffPanelOpen?.(); - }; - const addDiffSurfaceStable = useStableCallback(addDiffSurface); - const openChangesFromThreadPanel = () => { - addDiffSurface(); - }; - const openChangesFromThreadPanelStable = useStableCallback(openChangesFromThreadPanel); - const addFilesSurface = () => { - if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().open(activeThreadRef, "files"); - }; - const addFilesSurfaceStable = useStableCallback(addFilesSurface); const supportsThreadPullRequests = serverConfig?.environment.capabilities.threadPullRequests === true; const visiblePullRequests = visibleThreadPullRequests( @@ -3658,23 +1899,9 @@ export default function ChatView(props: ChatViewProps) { const visiblePullRequestCount = visiblePullRequests.length; const pullRequestsSurfaceAvailable = isServerThread && supportsThreadPullRequests && visiblePullRequestCount > 0; - const addPullRequestsSurface = () => { - if (!activeThreadRef || !pullRequestsSurfaceAvailable) return; - useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); - }; - const addPullRequestsSurfaceStable = useStableCallback(addPullRequestsSurface); const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( activeThreadRef?.environmentId ?? null, ); - const addDeviceSurface = () => { - if (!activeThreadRef) return; - if (!deviceState.onboardingCompleted || deviceState.hostStatus === "disabled") { - openDeviceSetupDialog(scopedThreadKey(activeThreadRef)); - return; - } - useRightPanelStore.getState().open(activeThreadRef, "device"); - }; - const addDeviceSurfaceStable = useStableCallback(addDeviceSurface); useAgentOpenedPreviews({ threadRef: activeThreadRef, deviceState, @@ -3685,11 +1912,6 @@ export default function ChatView(props: ChatViewProps) { miniPlayerSource: activePreviewMiniPlayer?.source ?? null, sheetLayout: shouldUsePlanSidebarSheet, }); - const openFileSurface = (relativePath: string) => { - if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().openFile(activeThreadRef, relativePath); - }; - const openFileSurfaceStable = useStableCallback(openFileSurface); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. const activeProjectRepository = sourceControlRepositorySelector( @@ -3735,159 +1957,12 @@ export default function ChatView(props: ChatViewProps) { visiblePullRequestCount, onDiffPanelOpen, }); - const closePreviewPanel = () => { - if (activeThreadRef) { - if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) { - usePreviewMiniPlayerStore - .getState() - .open(activeThreadRef, browserMiniPlayerSource(activeRightPanelSurface.resourceId)); - } else if (activeRightPanelSurface?.kind === "device" && activeRightPanelSurface.target) { - usePreviewMiniPlayerStore - .getState() - .open(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target }); - } - useRightPanelStore.getState().setMaximized(activeThreadRef, false); - useRightPanelStore.getState().close(activeThreadRef); - } - }; - const closePreviewPanelStable = useStableCallback(closePreviewPanel); - const togglePreviewPanel = () => { - if (!activeThreadRef || !browserAvailable) return; - if (previewPanelOpen) { - closePreviewPanel(); - return; - } - if (!canOperatePreview) return; - const activeTabId = activePreviewState.activeTabId; - if (activeTabId) { - useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); - } else { - createBrowserSurface(); - } - }; - const togglePreviewPanelStable = useStableCallback(togglePreviewPanel); - const addTerminalSurface = () => { - if (!activeThreadRef) return; - const launched = terminalActions.launchTerminal({ - place: (terminalId) => - useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId), - }); - if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); - void launched.result; - }; - const addTerminalSurfaceStable = useStableCallback(addTerminalSurface); - const splitPanelTerminal = (direction: "horizontal" | "vertical" = "horizontal") => { - if ( - !activeThreadRef || - activeRightPanelSurface?.kind !== "terminal" || - activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP - ) { - return; - } - const surfaceId = activeRightPanelSurface.id; - const launched = terminalActions.launchTerminal({ - place: (terminalId) => - useRightPanelStore - .getState() - .splitTerminal(activeThreadRef, surfaceId, terminalId, direction), - }); - if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); - void launched.result; - }; - const splitPanelTerminalStable = useStableCallback(splitPanelTerminal); - const splitPanelTerminalVertical = () => { - splitPanelTerminal("vertical"); - }; - const splitPanelTerminalVerticalStable = useStableCallback(splitPanelTerminalVertical); - const activatePanelTerminal = (terminalId: string) => { - if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; - useRightPanelStore - .getState() - .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }; - const activatePanelTerminalStable = useStableCallback(activatePanelTerminal); - const closePanelTerminal = (terminalId: string) => { - if ( - !hasTerminalWriteAccess() || - !activeThreadRef || - activeRightPanelSurface?.kind !== "terminal" - ) - return; - terminalActions.closeTerminalSession(terminalId); - storeCloseTerminal(activeThreadRef, terminalId); - useRightPanelStore - .getState() - .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }; - const closePanelTerminalStable = useStableCallback(closePanelTerminal); - const requestCloseTerminal = (terminalId: string) => { - if (!hasTerminalWriteAccess()) return; - const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); - void confirmTerminalClose([label]).then((confirmed) => { - if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { - closeTerminal(terminalId); - } - }); - }; - const requestCloseTerminalStable = useStableCallback(requestCloseTerminal); - const requestClosePanelTerminal = (terminalId: string) => { - if (!hasTerminalWriteAccess()) return; - const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); - void confirmTerminalClose([label]).then((confirmed) => { - if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { - closePanelTerminal(terminalId); - } - }); - }; - const requestClosePanelTerminalStable = useStableCallback(requestClosePanelTerminal); - const activateRightPanelSurface = (surface: RightPanelSurface) => { - if (!activeThreadRef) return; - explicitDiffOpenRef.current = resolveExplicitDiffOpenOnActivate({ - surfaceKind: surface.kind, - diffOpen, - activeThreadRef, - }); - useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); - if (surface.kind === "preview" && surface.resourceId) { - setActivePreviewTab(activeThreadRef, surface.resourceId); - } - if (surface.kind === "terminal") { - setTerminalFocusRequestId((value) => value + 1); - } - if (surface.kind === "diff" && !diffOpen) { - onDiffPanelOpen?.(); - } - }; - const activateRightPanelSurfaceStable = useStableCallback(activateRightPanelSurface); - const toggleRightPanel = () => { - if (!activeThreadRef) return; - if (rightPanelOpen) { - closePreviewPanel(); - return; - } - useRightPanelStore.getState().toggleVisibility(activeThreadRef); - }; - const toggleRightPanelStable = useStableCallback(toggleRightPanel); - const toggleThreadPanel = () => { - if (!activeThreadRef) return; - useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation); - }; - const toggleThreadPanelStable = useStableCallback(toggleThreadPanel); - useEffect(() => { - if (!canMaximizeRightPanel) return; - if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) { - useRightPanelStore.getState().setMaximized(routeThreadRef, true); + useEffect(() => { + if (!canMaximizeRightPanel) return; + if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) { + useRightPanelStore.getState().setMaximized(routeThreadRef, true); } }, [canMaximizeRightPanel, routeThreadRef]); - const toggleRightPanelMaximized = () => { - if (!canMaximizeRightPanel || !activeThreadRef) return; - useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized); - }; - const toggleRightPanelMaximizedStable = useStableCallback(toggleRightPanelMaximized); const { closeSurface: closeRightPanelSurface, closeOtherSurfaces: closeOtherRightPanelSurfacesStable, @@ -3898,50 +1973,6 @@ export default function ChatView(props: ChatViewProps) { terminalLabelsById: activeTerminalLabelsById, }); const closeRightPanelSurfaceStable = closeRightPanelSurface; - const moveRightPanelSurface = (surfaceId: string, toIndex: number) => { - if (activeThreadRef) - useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex); - }; - const moveRightPanelSurfaceStable = useStableCallback(moveRightPanelSurface); - const copyRightPanelFilePath = (relativePath: string) => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: "Clipboard API unavailable.", - }), - ); - return; - } - - void navigator.clipboard.writeText(relativePath).then( - () => { - toastManager.add({ - type: "success", - title: "Path copied", - description: relativePath, - }); - }, - (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - ); - }; - const copyRightPanelFilePathStable = useStableCallback(copyRightPanelFilePath); - useEffect( - () => - subscribePreviewAction((action) => { - if (action === "toggle-panel") togglePreviewPanelStable(); - }), - [togglePreviewPanelStable], - ); const persistThreadSettingsForNextTurn = async (input: { threadId: ThreadId; createdAt: string; @@ -4008,519 +2039,38 @@ export default function ChatView(props: ChatViewProps) { return result; }; - // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during - // thread switches. LegendList fires scroll events with isAtEnd=false while - // initialScrollAtEnd is settling; hiding is always immediate. - const showScrollDebouncer = useRef( - new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), - ); - const timelineScrollIntentRef = useRef<"toward-end" | "away-from-end" | null>(null); - const timelineScrollModeRef = useRef("following-end"); - // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd - // re-pins on its own (independent of the refs), so the timeline needs a - // render-visible flag to switch it off once the user scrolls away. - const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true); - const pendingTimelineAnchorRef = useRef(null); - const positionedTimelineAnchorRef = useRef(null); - const settledTimelineAnchorRef = useRef(null); - const activeTimelineAnchorIndexRef = useRef(null); - const observedTimelineActivityRef = useRef(null); - const anchorUserScrollGenerationRef = useRef(0); - const cancelPositionRestoreRef = useRef<(() => void) | null>(null); - const liveFollowUserScrollGenerationRef = useRef(0); - const pendingAnchorScrollRestoreRef = useRef<{ - readonly messageId: MessageId; - readonly offset: number; - readonly userScrollGeneration: number; - } | null>(null); - const anchorScrollRestoreFrameRef = useRef(null); - const cancelTimelineLiveFollowForUserNavigation = () => { - cancelPositionRestoreRef.current?.(); - anchorUserScrollGenerationRef.current += 1; - const wasProgrammaticScrollMode = timelineScrollModeRef.current !== "free-scrolling"; - timelineScrollModeRef.current = "free-scrolling"; - liveFollowUserScrollGenerationRef.current = null; - setTimelineLiveFollowEnabled(false); - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - pendingAnchorScrollRestoreRef.current = null; - if (anchorScrollRestoreFrameRef.current !== null) { - cancelAnimationFrame(anchorScrollRestoreFrameRef.current); - anchorScrollRestoreFrameRef.current = null; - } - if (wasProgrammaticScrollMode) { - // While following or anchoring, our scrollToEnd/scrollToOffset calls can - // sit in LegendList's pending-imperative-scroll queue (it defers them - // while layout settles) and fire seconds later with stale targets, - // yanking the view away after the user scrolled. Starting a new - // imperative scroll cancels everything queued; targeting an item that - // is not in the data makes the new request itself resolve without ever - // scrolling, so this is a pure cancel. - void legendListRef.current?.scrollToItem({ - item: TIMELINE_SCROLL_CANCEL_SENTINEL, - animated: false, - }); - // An already-started animated scroll (behavior: smooth) keeps running in - // the browser regardless of the queue; a same-position instant write is - // the only way to halt it where it is. - const scrollNode = legendListRef.current?.getScrollableNode() as - | { scrollTop?: number } - | null - | undefined; - const currentScrollTop = scrollNode?.scrollTop; - if (scrollNode && typeof currentScrollTop === "number") { - scrollNode.scrollTop = currentScrollTop; - } - } - }; - const cancelTimelineLiveFollowForUserNavigationStable = useStableCallback( - cancelTimelineLiveFollowForUserNavigation, - ); - const cancelTimelineLiveFollowForUserNavigationRef = useRef( + const { + showScrollToBottom, + timelineLiveFollowEnabled, + cancelPositionRestoreRef, + isTimelineAtLogicalEndStable, cancelTimelineLiveFollowForUserNavigationStable, - ); - useEffect(() => { - cancelTimelineLiveFollowForUserNavigationRef.current = - cancelTimelineLiveFollowForUserNavigationStable; - }, [cancelTimelineLiveFollowForUserNavigationStable]); - useEffect(() => { - const previous = observedTimelineActivityRef.current; - const dispatchedUserItem = - previous?.hydrated && - previous.threadKey === activeThreadKey && - activeActivityRun !== null && - previous.runId !== activeActivityRun.runId - ? serverProjection?.visibleTurnItems.find( - (row) => row.item.type === "user_message" && row.item.runId === activeActivityRun.runId, - ) - : undefined; - const { observation, anchorMessageId: messageId } = observeTimelineRun(previous, { - threadKey: activeThreadKey, - hydrated: !isServerThread || (serverProjection !== null && threadStatus === "live"), - runId: activeActivityRun?.runId ?? null, - queued: activeActivityRun?.status === "queued", - messageId: - dispatchedUserItem?.item.type === "user_message" ? dispatchedUserItem.item.messageId : null, - }); - observedTimelineActivityRef.current = observation; - if (messageId === null) return; - if ( - pendingTimelineAnchorRef.current !== null || - timelineScrollModeRef.current === "free-scrolling" - ) { - return; - } - - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = messageId; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor({ threadKey: activeThreadKey, messageId }); - }, [activeActivityRun, activeThreadKey, isServerThread, serverProjection, threadStatus]); - const timelineRealContentOverflowsViewport = (list?: LegendListRef | null) => - timelineContentOverflowsViewport((list ?? legendListRef.current)?.getState(), { - composerInset: composerTimelineInset, - anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, - }); - const timelineRealContentOverflowsViewportStable = useStableCallback( - timelineRealContentOverflowsViewport, - ); - const pageScrollControllerRef = useRef | null>( - null, - ); - const handlePageScrollStart = useEffectEvent((key: PageScrollKey) => { - timelineScrollIntentRef.current = key === "PageUp" ? "away-from-end" : "toward-end"; - composerRef.current?.collapseForTimelineScrollKey(key); - if ( - (key === "PageUp" && timelineRealContentOverflowsViewportStable()) || - !isTimelineAtLogicalEndStable() - ) { - cancelTimelineLiveFollowForUserNavigationStable(); - } - }); - useEffect(() => { - const controller = createPageScrollController({ - getContainer: () => legendListRef.current?.getScrollableNode() ?? null, - getScrollPaddingBottomPx: () => composerOverlayElement?.getBoundingClientRect().height ?? 0, - onScrollStart: handlePageScrollStart, - }); - pageScrollControllerRef.current = controller; - - return () => { - controller.dispose(); - if (pageScrollControllerRef.current === controller) { - pageScrollControllerRef.current = null; - } - }; - }, [composerOverlayElement]); - const onComposerPageScrollKeyDown = (key: PageScrollKey) => { - pageScrollControllerRef.current?.handleKeyDown(key); - }; - const onComposerPageScrollKeyDownStable = useStableCallback(onComposerPageScrollKeyDown); - const onComposerPageScrollKeyUp = (key: string) => { - pageScrollControllerRef.current?.handleKeyUp(key); - }; - const onComposerPageScrollKeyUpStable = useStableCallback(onComposerPageScrollKeyUp); - const onComposerPageScrollRelease = () => { - pageScrollControllerRef.current?.releaseActiveKey(); - }; - const onComposerPageScrollReleaseStable = useStableCallback(onComposerPageScrollRelease); - const scrollToEnd = (animated: boolean = false) => { - cancelPositionRestoreRef.current?.(); - isAtEndRef.current = true; - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor(releaseChatTimelineAnchor); - // The anchored end space must be gone before the scroll measures, or the - // list lands short of the real end (#6519). - requestAnimationFrame(() => { - void legendListRef.current?.scrollToEnd?.({ animated }); - }); - }; - const scrollToEndStable = useStableCallback(scrollToEnd); - useEffect(() => { - const composer = composerRef; - let removeListeners: (() => void) | null = null; - let frame: number | null = null; - const attach = (remainingAttempts: number) => { - frame = requestAnimationFrame(() => { - frame = null; - const scrollNode = legendListRef.current?.getScrollableNode(); - if (!scrollNode) { - // The list may not have mounted on the first frame after a thread - // switch — without a retry the opt-out listeners never attach and - // live-follow becomes impossible to escape for the whole thread. - if (remainingAttempts > 0) { - attach(remainingAttempts - 1); - } - return; - } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); - }; - // The gestures below must only break follow when they can actually - // move the viewport away from the live edge (#5566): a spurious break - // while pinned at the end produces no scroll event, never re-arms, - // and streaming silently stops following. Underflowing content can't - // scroll at all, so nothing there should break follow. - const contentScrollsUp = () => timelineRealContentOverflowsViewportStable(); - // The follow re-arm band, not the strict flag: streaming growth makes - // isAtEnd flicker false for a frame before the follow scroll catches - // up, and a gesture landing in that window while still pinned would - // otherwise break follow with no scroll event left to re-arm it. - const viewportIsAwayFromEnd = () => - resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; - const handleWheel = (event: WheelEvent) => { - if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY)) - return; - if (event.deltaY > 0) { - timelineScrollIntentRef.current = "toward-end"; - if (isAtEndRef.current) { - composer.current?.restoreAfterTimelineReachedEnd(); - } - } else if (event.deltaY < 0) { - timelineScrollIntentRef.current = "away-from-end"; - } - if (event.deltaY < 0 && contentScrollsUp()) { - handleManualNavigation(); - } - }; - // Touch direction isn't observable here (touchmove fires on any - // finger motion, scrolling or not), so break only once the drag has - // actually carried the viewport out of the end band — an upward flick - // gets there within its first few events and later touchmoves break. - const handleTouchMove = () => { - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - }; - // Scrollbar drags produce no wheel/touch events; they are the only - // pointerdowns whose target is the scroll node itself rather than a - // message row. - const handlePointerDown = (event: PointerEvent) => { - if (event.target === scrollNode) { - if (contentScrollsUp()) { - handleManualNavigation(); - } - return; - } - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - }; - // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and - // pointer events entirely; without this the timeline yanks back to - // the end on the next stream chunk. Clicking message text can leave - // DOM focus on body, so these keys must also be heard at document. - const handleKeyDown = (event: KeyboardEvent) => { - if ( - !(event.target instanceof Node) || - (!scrollNode.contains(event.target) && - event.target !== document.body && - event.target !== document.documentElement) || - event.defaultPrevented || - event.isComposing || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR) || - document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR) - ) { - return; - } - if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(event.key)) - return; - const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(event.key) ? -1 : 1; - if ( - scrollNode.contains(event.target) && - !isTimelineScrollTarget(event.target, scrollNode, scrollDirection) - ) - return; - switch (event.key) { - case "PageUp": - case "Home": - case "ArrowUp": - timelineScrollIntentRef.current = "away-from-end"; - if (contentScrollsUp()) { - handleManualNavigation(); - composer.current?.collapseForTimelineScrollKey(event.key); - } - break; - case "PageDown": - case "End": - case "ArrowDown": - timelineScrollIntentRef.current = "toward-end"; - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - composer.current?.collapseForTimelineScrollKey(event.key); - if (isTimelineAtLogicalEndStable()) { - composer.current?.restoreAfterTimelineReachedEnd(); - } - break; - default: - break; - } - }; - scrollNode.addEventListener("wheel", handleWheel, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleTouchMove, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handlePointerDown, { - passive: true, - }); - document.addEventListener("keydown", handleKeyDown); - removeListeners = () => { - scrollNode.removeEventListener("wheel", handleWheel); - scrollNode.removeEventListener("touchmove", handleTouchMove); - scrollNode.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }); - }; - attach(12); - - return () => { - if (frame !== null) { - cancelAnimationFrame(frame); - } - removeListeners?.(); - }; - }, [ - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A thread switch remounts the list's scroll node, so the listeners reattach. - activeThread?.id, + onComposerPageScrollKeyDownStable, + onComposerPageScrollKeyUpStable, + onComposerPageScrollReleaseStable, + scrollToEnd, + onTimelineAnchorReadyStable, + onTimelineAnchorSizeChangedStable, + onToolOutputCollapsedAtEndStable, + onIsAtEndChangeStable, + anchorNewTurn, + } = useTimelineFollow({ + legendListRef, composerRef, - isTimelineAtLogicalEndStable, - timelineRealContentOverflowsViewportStable, - ]); - - const onTimelineAnchorReady = (messageId: MessageId, anchorIndex: number) => { - if (pendingTimelineAnchorRef.current === messageId) { - pendingTimelineAnchorRef.current = null; - } - activeTimelineAnchorIndexRef.current = anchorIndex; - if (positionedTimelineAnchorRef.current === messageId) { - return; - } - positionedTimelineAnchorRef.current = messageId; - settledTimelineAnchorRef.current = null; - const positionAnchor = (remainingAttempts: number) => { - requestAnimationFrame(() => { - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - const list = legendListRef.current; - if (!list) { - if (remainingAttempts > 0) { - positionAnchor(remainingAttempts - 1); - } - return; - } - const scrollNode = list.getScrollableNode(); - let finished = false; - const finishAnimatedPositioning = () => { - if (finished) { - return; - } - finished = true; - window.clearTimeout(fallbackTimer); - scrollNode.removeEventListener("scrollend", finishAnimatedPositioning); - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - const scrollOffset = list.getState().scroll; - void list.scrollToOffset({ offset: scrollOffset, animated: false }); - settledTimelineAnchorRef.current = messageId; - }; - const fallbackTimer = window.setTimeout(finishAnimatedPositioning, 750); - scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true }); - void list.scrollToIndex({ - index: anchorIndex, - animated: true, - viewPosition: 0, - viewOffset: CHAT_LIST_ANCHOR_OFFSET, - }); - }); - }; - requestAnimationFrame(() => positionAnchor(12)); - }; - const onTimelineAnchorReadyStable = useStableCallback(onTimelineAnchorReady); - const onTimelineAnchorSizeChanged = (messageId: MessageId) => { - if (settledTimelineAnchorRef.current !== messageId) { - return; - } - if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { - return; - } - const scrollOffset = legendListRef.current?.getState().scroll; - if (scrollOffset === undefined) { - return; - } - if (pendingAnchorScrollRestoreRef.current === null) { - pendingAnchorScrollRestoreRef.current = { - messageId, - offset: scrollOffset, - userScrollGeneration: anchorUserScrollGenerationRef.current, - }; - } - if (anchorScrollRestoreFrameRef.current !== null) { - return; - } - anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => { - anchorScrollRestoreFrameRef.current = null; - const pending = pendingAnchorScrollRestoreRef.current; - pendingAnchorScrollRestoreRef.current = null; - if ( - pending && - settledTimelineAnchorRef.current === pending.messageId && - pending.userScrollGeneration === anchorUserScrollGenerationRef.current - ) { - const list = legendListRef.current; - const currentScrollOffset = list?.getState().scroll; - if ( - typeof currentScrollOffset === "number" && - Math.abs(currentScrollOffset - pending.offset) <= 2 - ) { - void list?.scrollToOffset({ offset: pending.offset, animated: false }); - } - } - }); - }; - const onTimelineAnchorSizeChangedStable = useStableCallback(onTimelineAnchorSizeChanged); - - const onToolOutputCollapsedAtEnd = () => { - composerRef.current?.restoreAfterTimelineReachedEnd(); - }; - const onToolOutputCollapsedAtEndStable = useStableCallback(onToolOutputCollapsedAtEnd); - - const onIsAtEndChange = (isAtEnd: boolean) => { - if ( - !isAtEnd && - liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current - ) { - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - return; - } - if (isAtEndRef.current === isAtEnd) return; - isAtEndRef.current = isAtEnd; - if (isAtEnd) { - if (timelineScrollIntentRef.current === "toward-end") { - composerRef.current?.restoreAfterTimelineReachedEnd(); - } - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - setTimelineAnchor(releaseChatTimelineAnchor); - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - } else { - timelineScrollModeRef.current = "free-scrolling"; - liveFollowUserScrollGenerationRef.current = null; - showScrollDebouncer.current.maybeExecute(); - } - }; - const onIsAtEndChangeStable = useStableCallback(onIsAtEndChange); - - useLayoutEffect(() => { - if (timelineScrollModeRef.current !== "anchoring-new-turn") return; - if ( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId: timelineAnchorMessageId, - liveFollowEnabled: timelineLiveFollowEnabled, - runningTurnId: activeRunningTurnId, - timelineEntries, - }) - ) { - scrollToEndStable(); - } - }, [ + composerOverlayElement, + composerTimelineInset, + routeThreadKey, + activeThreadKey, + activeThreadId: activeThread?.id, + isServerThread, + serverProjection, + threadStatus, + activeActivityRun, activeRunningTurnId, - scrollToEndStable, - timelineAnchorMessageId, timelineEntries, - timelineLiveFollowEnabled, - ]); - - // A thread switch restores that thread's remembered follow state; the refs - // the scroll handlers read catch up after commit. - const [timelineThreadReset, setTimelineThreadReset] = useState({ - threadKey: activeThreadKey, - followEnd: true, + timelineAnchorMessageId, + setTimelineAnchor, }); - if (timelineThreadReset.threadKey !== activeThreadKey) { - const followEnd = readTimelinePosition(routeThreadKey)?.atEnd !== false; - setTimelineThreadReset({ threadKey: activeThreadKey, followEnd }); - setTimelineLiveFollowEnabled(followEnd); - setShowScrollToBottom(!followEnd); - } - useEffect(() => { - const { followEnd } = timelineThreadReset; - isAtEndRef.current = followEnd; - timelineScrollIntentRef.current = null; - timelineScrollModeRef.current = followEnd ? "following-end" : "free-scrolling"; - liveFollowUserScrollGenerationRef.current = followEnd - ? anchorUserScrollGenerationRef.current - : null; - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - }, [timelineThreadReset]); useEffect(() => { if (!activeThread?.id || terminalUiState.terminalOpen) return; @@ -4649,6 +2199,12 @@ export default function ChatView(props: ChatViewProps) { canOverrideServerThreadEnvMode && pendingServerThreadBranchEntryForThread !== null ? pendingServerThreadBranchEntryForThread.branch : (activeThread?.branch ?? null); + const startFromOrigin = isLocalDraftThread + ? (draftThread?.startFromOrigin ?? false) + : canOverrideServerThreadEnvMode + ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? + activeProjectSettings.settings.newWorktreesStartFromOrigin) + : false; const supportsExistingBranchWorktree = serverConfig?.environment.capabilities.existingBranchWorktree === true; // Older servers always create a branch. Fan-out never reads this: it starts every model on its @@ -4658,17 +2214,6 @@ export default function ChatView(props: ChatViewProps) { (isLocalDraftThread ? (draftThread?.createNewBranch ?? true) : (pendingServerThreadCreateNewBranchByThreadId[activeThread?.id ?? ""] ?? true)); - const pendingServerThreadStartFromRemote = - pendingServerThreadStartFromRemoteByThreadId[activeThread?.id ?? ""]; - const startFromRemote = isLocalDraftThread - ? (draftThread?.startFromRemote ?? null) - : canOverrideServerThreadEnvMode - ? pendingServerThreadStartFromRemote !== undefined - ? pendingServerThreadStartFromRemote - : activeProjectSettings.settings.newWorktreesStartFromOrigin - ? "origin" - : null - : null; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, isGitRepo, @@ -4698,16 +2243,49 @@ export default function ChatView(props: ChatViewProps) { modelStrip.offsetHeight + Number.parseFloat(getComputedStyle(modelStrip).marginTop), ) : 0; + const noticeStack = composerOverlayElement?.querySelector( + '[data-composer-banner-stack="true"]', + ); + const expandedNoticesHeight = noticeStack + ? Math.max( + 0, + noticeStack.getBoundingClientRect().height - + Number.parseFloat(getComputedStyle(noticeStack).minHeight), + ) + : 0; const nextInset = resolveComposerTimelineInset({ currentInset: composerTimelineInsetRef.current, - overlayHeight: nextHeight, + overlayHeight: height, isResting, restingOnlyHeight, + expandedNoticesHeight, }); if (composerTimelineInsetRef.current !== nextInset) { composerTimelineInsetRef.current = nextInset; setComposerTimelineInset(nextInset); } + // The terminal drawer and the empty placeholder lay out against the + // expanded composer. The timeline inset already holds it through the + // desktop resting row; remembering it while the mobile strip is collapsed + // keeps both still when focus moves between the composer and the + // terminal, instead of re-clamping the drawer against each intermediate + // composer height. A collapsed strip can still grow (approvals, tasks), so + // the reservation follows it up. A hero composer's overlay fills the pane, + // so only its stack counts there. + const stack = composerOverlayElement?.querySelector( + '[data-chat-composer-stack="true"]', + ); + const expandedComposerHeight = + isDraftHeroState && stack ? Math.ceil(stack.getBoundingClientRect().height) : nextInset; + const mobileCollapsed = Boolean( + stack?.querySelector('[data-chat-composer-mobile-collapsed="true"]'), + ); + if (!mobileCollapsed) composerExpandedHeightRef.current = expandedComposerHeight; + const reservedHeight = Math.max(composerExpandedHeightRef.current, expandedComposerHeight); + if (composerReservedHeightRef.current !== reservedHeight) { + composerReservedHeightRef.current = reservedHeight; + setComposerReservedHeight(reservedHeight); + } const mainSurface = composerOverlayElement?.querySelector( '[data-chat-composer-main-surface="true"]', ); @@ -4741,6 +2319,8 @@ export default function ChatView(props: ChatViewProps) { useLayoutEffect(() => { if (!composerOverlayElement) return; composerTimelineInsetRef.current = 0; + composerReservedHeightRef.current = 0; + composerExpandedHeightRef.current = 0; publishComposerOverlayHeightStable(composerOverlayElement.getBoundingClientRect().height); // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A thread switch must re-measure the overlay for the new timeline. }, [activeThreadKey, composerOverlayElement, publishComposerOverlayHeightStable]); @@ -4753,14 +2333,28 @@ export default function ChatView(props: ChatViewProps) { }; updateHeight(); - return observeResize(composerOverlayElement, updateHeight); + // The hero overlay fills the pane, so the composer's own growth only shows on its stack. + const stack = isDraftHeroState + ? composerOverlayElement.querySelector('[data-chat-composer-stack="true"]') + : null; + return observeResize( + stack ? [composerOverlayElement, stack] : composerOverlayElement, + updateHeight, + ); + }, [ + composerOverlayElement, + isDraftHeroState, + publishComposerOverlayHeightStable, // oxlint-disable-next-line react/exhaustive-effect-dependencies -- The scroll-to-bottom button changes the overlay's height without resizing the observed node first. - }, [composerOverlayElement, publishComposerOverlayHeightStable, showScrollToBottom]); + showScrollToBottom, + ]); useLayoutEffect(() => { if (composerMountedRef.current === composerMounted) return; composerMountedRef.current = composerMounted; if (!composerOverlayElement) return; composerTimelineInsetRef.current = 0; + composerReservedHeightRef.current = 0; + composerExpandedHeightRef.current = 0; publishComposerOverlayHeightStable(composerOverlayElement.getBoundingClientRect().height); }, [composerMounted, composerOverlayElement, publishComposerOverlayHeightStable]); const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef); @@ -4797,7 +2391,6 @@ export default function ChatView(props: ChatViewProps) { }, ); }; - const copyActiveThreadReferenceStable = useStableCallback(copyActiveThreadReference); const pullRequestPanelTarget = activeThread ? threadPullRequestPanelTarget({ projectId: activeThread.projectId, @@ -4806,498 +2399,118 @@ export default function ChatView(props: ChatViewProps) { branchPullRequest: activeThreadShell?.branchPullRequest ?? activeThread.branchPullRequest, }) : null; - const addPullRequestSurface = () => { - if (!supportsPullRequests || activeThreadRef === null || pullRequestPanelTarget === null) - return; - useRightPanelStore.getState().openPullRequest(activeThreadRef, pullRequestPanelTarget); - }; - const addPullRequestSurfaceStable = useStableCallback(addPullRequestSurface); - const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; - const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; - const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; - const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; - const activeThreadPinned = supportsPinning && activeThreadShell?.pinnedAt != null; - // The compiler caches render results on their inputs, so a clock read in render - // stays frozen until the shell changes. The clock is state: reset with the shell, - // and moved by the wake timer. - const [snoozeClockState, setSnoozeClockState] = useState(() => ({ - shell: activeThreadShell, - now: new Date().toISOString(), - })); - if (snoozeClockState.shell !== activeThreadShell) { - setSnoozeClockState({ shell: activeThreadShell, now: new Date().toISOString() }); - } - const snoozeClock = snoozeClockState.now; - const activeThreadSnoozed = - activeThreadShell !== null && - supportsSnooze && - effectiveSnoozed(activeThreadShell, { now: snoozeClock }); - useEffect(() => { - if (!activeThreadSnoozed) return; - const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? ""); - if (!Number.isFinite(wakeAtMs)) return; - const id = window.setTimeout( - () => setSnoozeClockState((clock) => ({ ...clock, now: new Date().toISOString() })), - Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647), - ); - return () => window.clearTimeout(id); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A clock that lands before the wake time (timer clamping, clock skew) re-arms the timer. - }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeClock]); - const activeThreadWokeAt = - activeThreadShell !== null && supportsSnooze - ? threadWokeAt(activeThreadShell, { now: snoozeClock }) - : null; - const acknowledgeThreadWoke = useAcknowledgeThreadWoke(); - const acknowledgeActiveThreadWoke = () => { - if (activeThreadRef === null || activeThreadWokeAt === null) return; - acknowledgeThreadWoke(activeThreadRef, activeThreadWokeAt); - }; - const acknowledgeActiveThreadWokeStable = useStableCallback(acknowledgeActiveThreadWoke); - const activeThreadLocalVisitedAt = useUiStateStore((store) => - activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], - ); - const activeThreadLastVisitedAt = resolveThreadLastVisitedAt( - activeThreadShell?.lastVisitedAt, - activeThreadLocalVisitedAt, - ); - const activeThreadWokeVisible = isThreadWokeUnseen({ - wokeAt: activeThreadWokeAt, - settled: activeThreadShell?.settledOverride === "settled", - lastVisitedAt: activeThreadLastVisitedAt, - latestRunCompletedAt: activeLatestRun?.completedAt ?? null, - }); - const activeThreadSettled = - supportsSettlement && activeThreadShell?.settledOverride === "settled"; - const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, { - reportFailure: false, - }); - const [unsettlingThreadKey, setUnsettlingThreadKey] = useState(null); - const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; - const handleUnsettleActiveThread = async () => { - if ( - !activeThreadRef || - !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) - ) - return; - const threadKey = scopedThreadKey(activeThreadRef); - setUnsettlingThreadKey(threadKey); - // Commands settle into a result, so this always runs. - const result = await unsettleThreadMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, reason: "user" }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to un-settle thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); - }; - const handleUnsettleActiveThreadStable = useStableCallback(handleUnsettleActiveThread); - const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { - reportFailure: false, + const { + activateRightPanelSurfaceStable, + addDeviceSurfaceStable, + addDiffSurfaceStable, + addFilesSurfaceStable, + addPullRequestSurfaceStable, + addPullRequestsSurfaceStable, + closePreviewPanelStable, + copyRightPanelFilePathStable, + createBrowserSurface, + createBrowserSurfaceStable, + moveRightPanelSurfaceStable, + onOpenTurnDiffStable, + openChangesFromThreadPanelStable, + openFileSurfaceStable, + toggleRightPanelMaximizedStable, + toggleRightPanelStable, + toggleThreadPanelStable, + shortcuts: rightPanelShortcuts, + } = useRightPanelActions({ + activeThreadRef, + activeProject, + activeRightPanelSurface, + activePreviewState, + deviceState, + pullRequestPanelTarget, + threadPanelPresentation, + isServerThread, + isGitRepo, + browserAvailable, + canOperatePreview, + diffOpen, + previewPanelOpen, + rightPanelOpen, + canMaximizeRightPanel, + rightPanelMaximized, + supportsPullRequests, + pullRequestsSurfaceAvailable, + gitCwd, + activeProjectCwd, + explicitDiffOpenRef, + setTerminalFocusRequestId, + onDiffPanelOpen, + closeRightPanelSurface, }); - const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState(null); - const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; - const handleUnsnoozeActiveThread = async () => { - if ( - !activeThreadRef || - !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) - ) - return; - const threadKey = scopedThreadKey(activeThreadRef); - setUnsnoozingThreadKey(threadKey); - // Commands settle into a result, so this always runs. - const recovery = activeThreadShell?.limitRecovery; - const recoveryOwnsSnooze = - recovery?.snooze === true && - recovery.runId === activeThreadShell?.latestRun?.runId && - activeThreadShell?.snoozedUntil != null && - Date.parse(activeThreadShell.snoozedUntil) === Date.parse(recovery.resetAt); - const result = recoveryOwnsSnooze - ? await updateThreadMetadata({ - environmentId: activeThreadRef.environmentId, - input: { - threadId: activeThreadRef.threadId, - limitRecovery: { runId: recovery.runId, resetAt: recovery.resetAt, snooze: false }, - }, - }) - : await unsnoozeThreadMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, reason: "user" }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to wake thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); - }; - const handleUnsnoozeActiveThreadStable = useStableCallback(handleUnsnoozeActiveThread); - const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); - const [revealedBranchMismatchKey, setRevealedBranchMismatchKey] = useState(null); - const [, setBranchMismatchDismissTick] = useState(0); - const composerHasDraftContent = useComposerDraftStore((store) => - composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)), - ); + const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; const nowMinute = useNowMinute(); - const activeBranchMismatchKey = branchMismatchKey( - activeThread?.id ?? null, + const { + acknowledgeActiveThreadWoke, + activeBranchMismatchKey, + backgroundWorkBanner, + childInputBanner, + goalBanner, + handleSwitchCheckoutToThread, + handleSwitchCheckoutToThreadStable, + isRestoringThreadBranch, + onOpenRelatedThread, + onOpenRelatedThreadStable, + sendStandaloneCommand, + setBranchMismatchDismissTick, + showBranchMismatchBanner, + threadStatusLine, + shortcuts: threadLifecycleShortcuts, + } = useThreadLifecycle({ + environmentId, + routeThreadKey, + currentRouteThreadKeyRef, + canOperateThread, + clientSettingsHydrated, + activeThread, + activeThreadId, + activeThreadRef, + activeThreadKey, + activeThreadShell, + activeLatestRun, + activeProjectCwd, + serverConfig, + runtimeMode, + isWorking, + pendingBackgroundTasks, + activePendingApproval, + activePendingUserInput, localCheckoutBranchMismatch, - ); - const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ - hasMismatch: localCheckoutBranchMismatch !== null, - isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey), - composerHasContent: composerHasDraftContent, - wasShownForCurrentMismatch: - revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey, - }); - const nextRevealedBranchMismatchKey = showBranchMismatchBanner - ? activeBranchMismatchKey - : revealedBranchMismatchKey !== activeBranchMismatchKey - ? null - : revealedBranchMismatchKey; - if (nextRevealedBranchMismatchKey !== revealedBranchMismatchKey) { - setRevealedBranchMismatchKey(nextRevealedBranchMismatchKey); - } - const handleSwitchCheckoutToThread = async () => { - if (!canWriteSourceControl) return; - if ( - !activeProjectCwd || - !activeThread || - !localCheckoutBranchMismatch || - isRestoringThreadBranch - ) { - return; - } - setIsRestoringThreadBranch(true); - const checkoutResult = await switchGitRef({ - environmentId, - input: { - cwd: activeProjectCwd, - refName: localCheckoutBranchMismatch.threadBranch, - }, - }); - if (checkoutResult._tag === "Failure") { - setIsRestoringThreadBranch(false); - if (!isAtomCommandInterrupted(checkoutResult)) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to switch checkout", - description: chatActionErrorMessage(squashAtomCommandFailure(checkoutResult)), - }), - ); - } - return; - } - - if (!readEnvironmentScope(environmentId, AuthSourceControlWriteScope)) { - setIsRestoringThreadBranch(false); - return; - } - const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch; - if (nextBranch !== activeThread.branch) { - const updateResult = await updateThreadMetadata({ - environmentId, - input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null }, - }); - if (updateResult._tag === "Failure") { - setIsRestoringThreadBranch(false); - if (!isAtomCommandInterrupted(updateResult)) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Checkout switched, but the thread could not be updated", - description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)), - }), - ); - } - gitStatusQuery.refresh(); - return; - } - } - gitStatusQuery.refresh(); - setIsRestoringThreadBranch(false); - scheduleComposerFocus(); - }; - const handleSwitchCheckoutToThreadStable = useStableCallback(() => { - void handleSwitchCheckoutToThread(); + gitStatusQuery, + nowMinute, + composerRef, + composerDraftTarget, + sendInFlightRef, + setOptimisticUserMessages, + setThreadError, + clearUsageLimitsFor, + beginLocalDispatch, + resetLocalDispatch, + scheduleComposerFocus, + scrollToEnd, + persistThreadSettingsForNextTurn, }); - // The interrupt path also accepts a completed run while its provider still - // has background work. - const activeBackgroundTasks = !isWorking && activeThread ? pendingBackgroundTasks : []; - const [stoppingBackgroundWorkKey, setStoppingBackgroundWorkKey] = useState(null); - const isStoppingBackgroundWork = - stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`; - const handleStopBackgroundWork = async () => { - if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) - return; - const requestKey = `${environmentId}:${activeThread.id}`; - setStoppingBackgroundWorkKey(requestKey); - const result = await interruptThreadTurn({ - environmentId, - input: { threadId: activeThread.id }, - }); - // Acceptance does not confirm termination. Allow retry while the provider - // finishes stopping the tasks or reports a failure. - setStoppingBackgroundWorkKey((current) => (current === requestKey ? null : current)); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to stop background work.", - ); - } - } - }; - const handleStopBackgroundWorkStable = useStableCallback(handleStopBackgroundWork); - const onOpenRelatedThread = (threadId: ThreadId) => { - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(environmentId, threadId)), - }); - }; - const onOpenRelatedThreadStable = useStableCallback(onOpenRelatedThread); - const childThreadInputs = useChildThreadInputs(activeThreadRef); - const childInputBannerItem = (() => { - const first = childThreadInputs[0]; - const parentAwaitingUser = - activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput; - if (!first || parentAwaitingUser) return null; - return { - id: `child-input:${first.id}`, - variant: "info", - priority: "activity", - icon: first.hasPendingApprovals ? : , - title: childThreadInputsTitle(childThreadInputs), - description: childThreadInputs.map((child, index) => ( - - {index > 0 ? " " : null} - - onOpenRelatedThreadStable(child.id)}> - {child.title} - - {index < childThreadInputs.length - 1 ? "," : null} - - - )), - actions: ( - - ), - }; - })() as ComposerBannerStackItem | null; - - const sendStandaloneCommand = async (text: string, failureMessage: string) => { - if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return; - const context = composerRef.current?.getSendContext(); - if (!context?.providerAvailable) return; - - const threadId = activeThread.id; - const messageId = newMessageId(); - const createdAt = new Date().toISOString(); - sendInFlightRef.current = true; - beginLocalDispatch(); - setThreadError(threadId, null); - setOptimisticUserMessages((messages) => [ - ...messages, - { - id: messageId, - role: "user", - text, - runId: null, - createdAt, - updatedAt: createdAt, - streaming: false, - }, - ]); - scrollToEnd(); - // Commands settle into a result, so this always runs. - const settingsResult = await persistThreadSettingsForNextTurn({ - threadId, - createdAt, - ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), - runtimeMode, - interactionMode: context.interactionMode, - }); - const result = - settingsResult._tag === "Failure" - ? settingsResult - : await startThreadTurn({ - environmentId, - input: { - threadId, - message: { messageId, role: "user", text, attachments: [] }, - modelSelection: context.selectedModelSelection, - runtimeMode, - interactionMode: context.interactionMode, - createdAt, - }, - }); - if (result._tag === "Failure") { - setOptimisticUserMessages((messages) => - messages.filter((message) => message.id !== messageId), - ); - resetLocalDispatch(); - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError(threadId, error instanceof Error ? error.message : failureMessage); - } - } else { - clearUsageLimitsFor(routeThreadKey); - } - sendInFlightRef.current = false; - }; - const sendStandaloneCommandStable = useStableCallback(sendStandaloneCommand); - const activeGoal = activeThreadShell?.goal ?? null; - const goalBannerItem = (() => { - if (!activeThread || activeGoal === null) return null; - const presentation = presentProviderGoal(activeGoal, isWorking); - return { - id: `goal:${activeThread.id}`, - variant: "default", - priority: "activity", - icon: , - title: - presentation.usage === null - ? presentation.title - : `${presentation.title} · ${presentation.usage}`, - description: presentation.objective, - actions: isWorking ? undefined : ( - <> - {presentation.canResume ? ( - - ) : null} - - - ), - }; - })() as ComposerBannerStackItem | null; - - const backgroundWorkBannerItem = (() => { - const presentation = presentPendingBackgroundWork(activeBackgroundTasks); - if (presentation === null || !activeThread) { - return null; - } - return { - id: `background-work:${activeThread.id}`, - variant: "default", - priority: "activity", - icon: ( -
- {rightPanelPresent && !shouldUsePlanSidebarSheet && activeThreadRef ? ( - {panelToggleControls}
+ ) : null + } surfaces={renderedRightPanelSurfaces} environmentId={activeThreadRef.environmentId} activeSurfaceId={renderedRightPanelSurface?.id ?? null} @@ -8601,63 +3902,7 @@ export default function ChatView(props: ChatViewProps) { deviceAvailable={activeThreadRef !== null} > {rightPanelContent} - - ) : null} - {rightPanelPresent && shouldUsePlanSidebarSheet && activeThreadRef ? ( - - {panelToggleControls}
- ) : null - } - surfaces={renderedRightPanelSurfaces} - environmentId={activeThreadRef.environmentId} - activeSurfaceId={renderedRightPanelSurface?.id ?? null} - pendingSurfaceIds={pendingFileSurfaceIds} - previewSessions={activePreviewState.sessions} - desktopByTabId={activePreviewState.desktopByTabId} - previewRuntimeTabId={resolvePreviewRuntimeTabId} - terminalLabelsById={activeTerminalLabelsById} - onActivate={activateRightPanelSurfaceStable} - onCloseSurface={closeRightPanelSurfaceStable} - onRenameDevice={(surfaceId, title) => { - if (activeThreadRef) - useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); - }} - onCloseOtherSurfaces={closeOtherRightPanelSurfacesStable} - onCloseSurfacesToRight={closeRightPanelSurfacesToRightStable} - onCloseAllSurfaces={closeAllRightPanelSurfacesStable} - onMoveSurface={moveRightPanelSurfaceStable} - onCopyFilePath={copyRightPanelFilePathStable} - onAddBrowser={() => createBrowserSurface()} - onAddBrowserInProfile={createBrowserSurfaceStable} - onAddTerminal={addTerminalSurfaceStable} - onAddDiff={addDiffSurfaceStable} - onAddFiles={addFilesSurfaceStable} - onAddPullRequest={addPullRequestSurfaceStable} - onAddPullRequests={addPullRequestsSurfaceStable} - onAddDevice={addDeviceSurfaceStable} - browserAvailable={canOperatePreview && browserAvailable} - terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} - pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={pullRequestsSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} - > - {rightPanelContent} - - + ) : null} @@ -8681,76 +3926,3 @@ function PreviewSessionSync(props: { readonly threadRef: ScopedThreadRef }) { usePreviewSession(props.threadRef); return null; } - -// The visit watermark is the one part of the chat view that follows the -// shell's updatedAt, which ticks while a run streams. Keeping it in a leaf lets -// ChatView read the quiet shell. -function ThreadVisitWatermark(props: { readonly threadRef: ScopedThreadRef }) { - const serverThread = useThreadShell(props.threadRef); - const threadKey = scopedThreadKey(props.threadRef); - const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLocalLastVisitedAt = useUiStateStore( - (store) => store.threadLastVisitedAtById[threadKey], - ); - const visitThreadMutation = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); - const lastDispatchedVisitRef = useRef(null); - const lastVisitDispatchAtRef = useRef(0); - - useEffect(() => { - if (!serverThread?.id) return; - const threadUpdatedAt = Date.parse(serverThread.updatedAt); - if (Number.isNaN(threadUpdatedAt)) return; - const effectiveLastVisitedAt = resolveThreadLastVisitedAt( - serverThread.lastVisitedAt, - activeThreadLocalLastVisitedAt, - ); - const lastVisitedAt = effectiveLastVisitedAt ? Date.parse(effectiveLastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return; - - if (serverThread.lastVisitedAt !== undefined) { - // Dedupe per watermark — the effect re-runs before the command's echo - // lands. The dedupe also keeps a mark-unread on the open thread sticky: - // the rewind leaves updatedAt untouched, so the already-dispatched key - // skips a fresh visit until new activity lands or the thread is reopened. - const dispatchKey = `${threadKey}:${serverThread.updatedAt}`; - if (lastDispatchedVisitRef.current === dispatchKey) return; - const dispatch = () => { - lastDispatchedVisitRef.current = dispatchKey; - lastVisitDispatchAtRef.current = Date.now(); - void visitThreadMutation({ - environmentId: serverThread.environmentId, - input: { threadId: serverThread.id, visitedAt: serverThread.updatedAt }, - }); - }; - const latestRunCompletedAtMs = serverThread.latestRun?.completedAt - ? Date.parse(serverThread.latestRun.completedAt) - : NaN; - const hasUnseenCompletion = - !Number.isNaN(latestRunCompletedAtMs) && - (Number.isNaN(lastVisitedAt) || latestRunCompletedAtMs > lastVisitedAt); - const elapsed = Date.now() - lastVisitDispatchAtRef.current; - if (hasUnseenCompletion || elapsed >= VISIT_DISPATCH_THROTTLE_MS) { - dispatch(); - return; - } - const timer = setTimeout(dispatch, VISIT_DISPATCH_THROTTLE_MS - elapsed); - return () => clearTimeout(timer); - } - - markThreadVisited( - scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - serverThread.updatedAt, - ); - }, [ - activeThreadLocalLastVisitedAt, - markThreadVisited, - threadKey, - serverThread?.environmentId, - serverThread?.id, - serverThread?.lastVisitedAt, - serverThread?.latestRun?.completedAt, - serverThread?.updatedAt, - visitThreadMutation, - ]); - return null; -} diff --git a/apps/web/src/components/CommandPalette.focus.browser.test.tsx b/apps/web/src/components/CommandPalette.focus.browser.test.tsx deleted file mode 100644 index 66d8795741a1..000000000000 --- a/apps/web/src/components/CommandPalette.focus.browser.test.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { act, useImperativeHandle, useRef } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { CommandPalette } from "./CommandPalette"; -import { useComposerHandleContext } from "../composerHandleContext"; -import { openCommandPalette } from "../commandPaletteBus"; -import { primaryServerKeybindingsAtom } from "../state/server"; - -vi.mock("@tanstack/react-router", () => ({ - useNavigate: () => vi.fn(), - useParams: () => null, - useLocation: () => "/settings", -})); -vi.mock("@effect/atom-react", async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - useAtomValue: (atom: unknown) => - atom === primaryServerKeybindingsAtom ? DEFAULT_RESOLVED_KEYBINDINGS : [], - }; -}); -vi.mock("../hooks/useTheme", () => ({ - useTheme: () => ({ theme: "default", resolvedTheme: "light", appearanceMode: "light" }), -})); -vi.mock("../hooks/useCustomThemes", () => ({ useCustomThemes: () => [] })); -vi.mock("../hooks/useEnvironmentTheme", () => ({ useEnvironmentThemeDefinitions: () => [] })); -vi.mock("../hooks/useSettings", async (importOriginal) => ({ - ...(await importOriginal()), - useClientSettings: () => ({}), -})); -vi.mock("../hooks/useScratchProject", () => ({ - useScratchProject: () => ({ scratchEnvironmentId: () => null }), -})); -vi.mock("../hooks/useNewProject", () => ({ useNewProject: () => vi.fn() })); -vi.mock("../hooks/useHandleNewThread", () => ({ useHandleNewThread: () => ({}) })); -vi.mock("../hooks/useOpenPanelPullRequestUrl", () => ({ useOpenPanelPullRequestUrl: () => null })); -vi.mock("../connection/useDesktopLocalBootstraps", () => ({ useDesktopLocalBootstraps: () => [] })); -vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); -vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); -vi.mock("../state/environments", async (importOriginal) => ({ - ...(await importOriginal()), - useEnvironments: () => ({ environments: [] }), - usePrimaryEnvironmentId: () => null, -})); -vi.mock("../state/entities", async (importOriginal) => ({ - ...(await importOriginal()), - useProjects: () => [], - useThreadShells: () => [], - useServerConfigs: () => new Map(), -})); -vi.mock("../state/queries", async (importOriginal) => ({ - ...(await importOriginal()), - useThreadSearch: () => ({ matches: [] }), -})); -vi.mock("../state/session", async (importOriginal) => ({ - ...(await importOriginal()), - useEnvironmentScope: () => false, -})); -vi.mock("../state/query", async (importOriginal) => ({ - ...(await importOriginal()), - useEnvironmentQuery: () => ({}), -})); -vi.mock("./settings/useAvailableSettingsSearchItems", () => ({ - useAvailableSettingsSearchItems: () => [], -})); - -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => { - root.unmount(); - await vi.runOnlyPendingTimersAsync(); - }); - container.remove(); - vi.unstubAllGlobals(); - vi.useRealTimers(); -}); - -async function flushFocus() { - await act(() => vi.advanceTimersByTimeAsync(0)); - await act(() => vi.advanceTimersToNextFrame()); -} - -async function dismissPalette() { - await act(() => - document.activeElement?.dispatchEvent( - new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }), - ), - ); - await flushFocus(); - expect(document.querySelector("[data-command-palette]")).toBeNull(); -} - -function Composer() { - const handle = useComposerHandleContext(); - const input = useRef(null); - useImperativeHandle( - handle, - () => - ({ - focusAtEnd: () => { - input.current?.focus(); - input.current?.setSelectionRange(input.current.value.length, input.current.value.length); - }, - }) as NonNullable["current"]>, - ); - return